diff --git a/packages/cli/bin/lildax.cjs b/packages/cli/bin/lildax.cjs new file mode 100644 index 0000000000000000000000000000000000000000..ab99b84b0f9b9e28f4549126b092a1c2d0f7124f --- /dev/null +++ b/packages/cli/bin/lildax.cjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +const childProcess = require("child_process") +const fs = require("fs") +const path = require("path") +const os = require("os") + +const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"] + +function run(target) { + const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" }) + child.on("error", (error) => { + console.error(error.message) + process.exit(1) + }) + const forwarders = {} + for (const signal of forwardedSignals) { + forwarders[signal] = () => { + try { + child.kill(signal) + } catch {} + } + process.on(signal, forwarders[signal]) + } + child.on("exit", (code, signal) => { + for (const forwardedSignal of forwardedSignals) process.removeListener(forwardedSignal, forwarders[forwardedSignal]) + if (signal) return process.kill(process.pid, signal) + process.exit(typeof code === "number" ? code : 0) + }) +} + +const envPath = process.env.OPENCODE_BIN_PATH +const scriptDir = path.dirname(fs.realpathSync(__filename)) +const cached = path.join(scriptDir, ".lildax") +const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform() +const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch() +const base = "@opencode-ai/cli-" + platform + "-" + arch +const binary = platform === "windows" ? "lildax.exe" : "lildax" + +function supportsAvx2() { + if (arch !== "x64") return false + if (platform === "linux") { + try { + return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) + } catch { + return false + } + } + if (platform === "darwin") { + try { + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { encoding: "utf8", timeout: 1500 }) + return result.status === 0 && (result.stdout || "").trim() === "1" + } catch { + return false + } + } + if (platform === "windows") { + const command = + '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' + for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { + try { + const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }) + if (result.status !== 0) continue + const output = (result.stdout || "").trim().toLowerCase() + if (output === "true" || output === "1") return true + if (output === "false" || output === "0") return false + } catch { + continue + } + } + } + return false +} + +const names = (() => { + const baseline = arch === "x64" && !supportsAvx2() + if (platform === "linux") { + const musl = (() => { + try { + if (fs.existsSync("/etc/alpine-release")) return true + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + return ((result.stdout || "") + (result.stderr || "")).toLowerCase().includes("musl") + } catch { + return false + } + })() + if (musl) + return arch === "x64" + ? baseline + ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] + : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] + : [`${base}-musl`, base] + return arch === "x64" + ? baseline + ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] + : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] + : [base, `${base}-musl`] + } + return arch === "x64" ? (baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]) : [base] +})() + +function findBinary(startDir) { + let current = startDir + for (;;) { + const modules = path.join(current, "node_modules") + if (fs.existsSync(modules)) + for (const name of names) { + const candidate = path.join(modules, name, "bin", binary) + if (fs.existsSync(candidate)) return candidate + } + const parent = path.dirname(current) + if (parent === current) return + current = parent + } +} + +const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) +if (!resolved) { + console.error( + "It seems that your package manager failed to install the right lildax CLI package. Try manually installing " + + names.map((name) => `"${name}"`).join(" or ") + + " package", + ) + process.exit(1) +} +run(resolved) diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts new file mode 100644 index 0000000000000000000000000000000000000000..f42d8b07b0d308a5f4cba63c7a3add54916d933d --- /dev/null +++ b/packages/cli/script/build.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { rm } from "fs/promises" +import path from "path" +import { Script } from "@opencode-ai/script" +import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" +import pkg from "../package.json" +import { modelsData } from "./generate" + +const dir = path.resolve(import.meta.dirname, "..") +const binary = "lildax" +process.chdir(dir) + +await rm("dist", { recursive: true, force: true }) + +const singleFlag = process.argv.includes("--single") +const baselineFlag = process.argv.includes("--baseline") +const skipInstall = process.argv.includes("--skip-install") +const sourcemapsFlag = process.argv.includes("--sourcemaps") +const plugin = createSolidTransformPlugin() + +const allTargets: { + os: string + arch: "arm64" | "x64" + abi?: "musl" + avx2?: false +}[] = [ + { os: "linux", arch: "arm64" }, + { os: "linux", arch: "x64" }, + { os: "linux", arch: "x64", avx2: false }, + { os: "linux", arch: "arm64", abi: "musl" }, + { os: "linux", arch: "x64", abi: "musl" }, + { os: "linux", arch: "x64", abi: "musl", avx2: false }, + { os: "darwin", arch: "arm64" }, + { os: "darwin", arch: "x64" }, + { os: "darwin", arch: "x64", avx2: false }, + { os: "win32", arch: "arm64" }, + { os: "win32", arch: "x64" }, + { os: "win32", arch: "x64", avx2: false }, +] + +const targets = singleFlag + ? allTargets.filter((item) => { + if (item.os !== process.platform || item.arch !== process.arch) return false + if (item.avx2 === false) return baselineFlag + return item.abi === undefined + }) + : allTargets + +if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` + +for (const item of targets) { + const target = [ + binary, + item.os === "win32" ? "windows" : item.os, + item.arch, + item.avx2 === false ? "baseline" : undefined, + item.abi, + ] + .filter(Boolean) + .join("-") + const name = target.replace(binary, "cli") + console.log(`building ${name}`) + const result = await Bun.build({ + entrypoints: ["./src/index.ts"], + tsconfig: "./tsconfig.json", + plugins: [plugin], + external: ["node-gyp"], + format: "esm", + minify: true, + sourcemap: sourcemapsFlag ? "linked" : "none", + splitting: true, + compile: { + autoloadBunfig: false, + autoloadDotenv: false, + autoloadTsconfig: true, + autoloadPackageJson: true, + target: target.replace(binary, "bun") as Bun.Build.CompileTarget, + outfile: `./dist/${name}/bin/${binary}`, + execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"], + windows: {}, + }, + define: { + OPENCODE_VERSION: `'${Script.version}'`, + OPENCODE_CLI_NAME: `'${binary}'`, + OPENCODE_MODELS_DEV: modelsData, + OPENCODE_CHANNEL: `'${Script.channel}'`, + OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined", + // FFF_LIBC selects the fff native lib variant: "musl" or "gnu". + FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined", + ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), + }, + }) + + if (!result.success) { + for (const log of result.logs) console.error(log) + process.exit(1) + } + + await Bun.write( + `./dist/${name}/package.json`, + JSON.stringify( + { + name: `@opencode-ai/${name}`, + version: Script.version, + license: "MIT", + repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, + os: [item.os], + cpu: [item.arch], + }, + null, + 2, + ), + ) +} diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts new file mode 100644 index 0000000000000000000000000000000000000000..e162f2ea7e5a6a6c500ec5a3a69ffa649e24593c --- /dev/null +++ b/packages/cli/script/generate.ts @@ -0,0 +1,7 @@ +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai" + +export const modelsData = process.env.MODELS_DEV_API_JSON + ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() + : await fetch(`${modelsUrl}/api.json`).then((response) => response.text()) + +console.log("Loaded models.dev snapshot") diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2855413ca6290bddced34db11e54dc4524360ea --- /dev/null +++ b/packages/cli/script/publish.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env bun +import { $ } from "bun" +import pkg from "../package.json" +import { Script } from "@opencode-ai/script" +import { fileURLToPath } from "url" + +const dir = fileURLToPath(new URL("..", import.meta.url)) +process.chdir(dir) + +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + +async function publish(dir: string, name: string, version: string) { + if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) + if (await published(name, version)) return console.log(`already published ${name}@${version}`) + await $`bun pm pack`.cwd(dir) + await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) +} + +const binaries: Record = {} +for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) { + const item = await Bun.file(`./dist/${filepath}`).json() + binaries[item.name] = item.version +} +console.log("binaries", binaries) +const version = Object.values(binaries)[0] + +await $`mkdir -p ./dist/${pkg.name}/bin` +await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax` +await Bun.file(`./dist/${pkg.name}/package.json`).write( + JSON.stringify( + { + name: pkg.name, + bin: { lildax: "./bin/lildax" }, + version, + license: pkg.license, + repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, + os: ["darwin", "linux", "win32"], + cpu: ["arm64", "x64"], + optionalDependencies: binaries, + }, + null, + 2, + ), +) + +await Promise.all( + Object.entries(binaries).map(([name, version]) => + publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version), + ), +) +await publish(`./dist/${pkg.name}`, pkg.name, version) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts new file mode 100644 index 0000000000000000000000000000000000000000..19d1f5e68bfd48c25e77eb42b8d7c18121bf1418 --- /dev/null +++ b/packages/cli/src/commands/commands.ts @@ -0,0 +1,52 @@ +import { Argument, Flag } from "effect/unstable/cli" +import { Spec } from "../framework/spec" + +declare const OPENCODE_CLI_NAME: string | undefined + +export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { + description: "OpenCode 2.0 preview command line interface", + commands: [ + Spec.make("api", { + description: "Make a request to the running server", + params: { + request: Argument.string("operation | method path").pipe( + Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"), + Argument.variadic({ min: 1, max: 2 }), + ), + data: Flag.string("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional), + header: Flag.string("header").pipe( + Flag.withAlias("H"), + Flag.withDescription("Request header in name:value form"), + Flag.atMost(100), + ), + param: Flag.keyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional), + }, + }), + Spec.make("debug", { + description: "Debugging and troubleshooting tools", + commands: [Spec.make("agents", { description: "List all agents" })], + }), + Spec.make("migrate", { description: "Migrate v1 data to v2" }), + Spec.make("service", { + description: "Manage the background server", + commands: [ + Spec.make("start", { description: "Start the background server" }), + Spec.make("restart", { description: "Restart the background server" }), + Spec.make("status", { description: "Show background server status" }), + Spec.make("stop", { description: "Stop the background server" }), + Spec.make("password", { + description: "Get or set the server password", + params: { value: Argument.string("value").pipe(Argument.optional) }, + }), + ], + }), + Spec.make("serve", { + description: "Start the v2 API server", + params: { + hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), + port: Flag.integer("port").pipe(Flag.optional), + register: Flag.boolean("register").pipe(Flag.withDefault(false)), + }, + }), + ], +}) diff --git a/packages/cli/src/commands/handlers/api.test.ts b/packages/cli/src/commands/handlers/api.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8e579dc266a194f3f5221148071736308ba35b8 --- /dev/null +++ b/packages/cli/src/commands/handlers/api.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { rawRequest, resolveOperation } from "./api" + +describe("api request resolution", () => { + test("resolves an operation ID with path and query parameters", () => { + expect( + resolveOperation( + { + paths: { + "/api/session/{sessionID}": { + get: { operationId: "v2.session.get" }, + }, + }, + }, + "v2.session.get", + { sessionID: "ses/a", workspace: "work" }, + ), + ).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" }) + }) + + test("rejects a missing path parameter", () => { + expect(() => + resolveOperation( + { paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } }, + "v2.session.get", + {}, + ), + ).toThrow("Missing path parameter: sessionID") + }) + + test("resolves curl-like method and path input", () => { + expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" }) + expect(rawRequest(["v2.session.list"])).toBeUndefined() + }) +}) diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf00394cb902b88db9016a6c3b9392a1dc4342e2 --- /dev/null +++ b/packages/cli/src/commands/handlers/api.ts @@ -0,0 +1,85 @@ +import { EOL } from "node:os" +import { Effect, Option } from "effect" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Daemon } from "../../services/daemon" + +const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) + +type Operation = { + operationId?: string +} + +type OpenApi = { + paths?: Record> +} + +export default Runtime.handler( + Commands.commands.api, + Effect.fn("cli.api")(function* (input) { + const daemon = yield* Daemon.Service + const transport = yield* daemon.transport() + const params = Option.getOrElse(input.param, () => ({})) + const request = yield* resolveRequest(transport, input.request, params) + const headers = new Headers(transport.headers) + for (const header of input.header) { + const index = header.indexOf(":") + if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`)) + headers.set(header.slice(0, index).trim(), header.slice(index + 1).trim()) + } + const body = Option.getOrUndefined(input.data) + if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") + + const response = yield* Effect.tryPromise(() => + fetch(new URL(request.path, transport.url), { + method: request.method, + headers, + body, + }), + ) + const output = yield* Effect.promise(() => response.text()) + if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL)) + }), +) + +export function resolveOperation(spec: OpenApi, operationID: string, params: Record) { + for (const [path, operations] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(operations)) { + if (!methods.has(method) || operation.operationId !== operationID) continue + return { method: method.toUpperCase(), path: interpolate(path, params) } + } + } + throw new Error(`Operation not found: ${operationID}`) +} + +export function rawRequest(input: readonly string[]) { + if (input.length !== 2 || !methods.has(input[0].toLowerCase()) || !input[1].startsWith("/")) return + return { method: input[0].toUpperCase(), path: input[1] } +} + +function resolveRequest( + transport: { url: string; headers: RequestInit["headers"] }, + input: readonly string[], + params: Record, +) { + const raw = rawRequest(input) + if (raw) return Effect.succeed(raw) + if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path")) + return Effect.tryPromise(async () => { + const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers }) + if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`) + return resolveOperation((await response.json()) as OpenApi, input[0], params) + }) +} + +function interpolate(path: string, params: Record) { + const used = new Set() + const pathname = path.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { + const value = params[name] + if (value === undefined) throw new Error(`Missing path parameter: ${name}`) + used.add(name) + return encodeURIComponent(value) + }) + const query = new URLSearchParams(Object.entries(params).filter(([name]) => !used.has(name))).toString() + return query ? `${pathname}?${query}` : pathname +} diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a0c20cb069af1d09a196b877e81bf80ea7461e5 --- /dev/null +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -0,0 +1,21 @@ +import { EOL } from "os" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.debug.commands.agents, + Effect.fn("cli.debug.agents")(function* () { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } })) + process.stdout.write( + JSON.stringify( + response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)), + null, + 2, + ) + EOL, + ) + }), +) diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0a9968e5d8e553c852b0aa724097d2a2c8de849 --- /dev/null +++ b/packages/cli/src/commands/handlers/default.ts @@ -0,0 +1,13 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect } from "effect" +import { Daemon } from "../../services/daemon" + +export default Runtime.handler(Commands, () => + Effect.gen(function* () { + const daemon = yield* Daemon.Service + const transport = yield* daemon.transport() + const { runTui } = yield* Effect.promise(() => import("../../tui")) + yield* runTui(transport) + }), +) diff --git a/packages/cli/src/commands/handlers/migrate.ts b/packages/cli/src/commands/handlers/migrate.ts new file mode 100644 index 0000000000000000000000000000000000000000..c73c7750df01b0846e726a350a3db32e1badb12c --- /dev/null +++ b/packages/cli/src/commands/handlers/migrate.ts @@ -0,0 +1,5 @@ +import * as Effect from "effect/Effect" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" + +export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run.")) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts new file mode 100644 index 0000000000000000000000000000000000000000..19b097453dc651c9c39f07be6d9b29cc50eba6df --- /dev/null +++ b/packages/cli/src/commands/handlers/serve.ts @@ -0,0 +1,46 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { Context, Layer, Option } from "effect" +import * as Effect from "effect/Effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { createServer } from "node:http" +import { createRoutes } from "@opencode-ai/server/routes" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Daemon } from "../../services/daemon" + +export default Runtime.handler( + Commands.commands.serve, + Effect.fn("cli.serve")(function* (input) { + return yield* Effect.scoped( + Effect.gen(function* () { + const daemon = yield* Daemon.Service + const address = yield* listen(input.hostname, input.port, yield* daemon.password()) + if (input.register) yield* daemon.register(address) + console.log(`server listening on ${HttpServer.formatAddress(address)}`) + return yield* Effect.never + }), + ) + }), +) + +function listen(hostname: string, port: Option.Option, password: string) { + if (Option.isSome(port)) return bind(hostname, port.value, password) + const next = (port: number): ReturnType => + bind(hostname, port, password).pipe( + Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))), + ) + return next(4096) +} + +function bind(hostname: string, port: number, password: string) { + return Layer.build( + HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe( + Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })), + Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node]))), + ), + ).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address)) +} diff --git a/packages/cli/src/commands/handlers/service/password.ts b/packages/cli/src/commands/handlers/service/password.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bf49d50d049ba7a26d97069955f0ed551137464 --- /dev/null +++ b/packages/cli/src/commands/handlers/service/password.ts @@ -0,0 +1,16 @@ +import { EOL } from "os" +import { Option } from "effect" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.service.commands.password, + Effect.fn("cli.service.password")(function* (input) { + const daemon = yield* Daemon.Service + const value = Option.getOrUndefined(input.value) + if (value !== undefined) yield* daemon.stop() + process.stdout.write((yield* daemon.password(value)) + EOL) + }), +) diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts new file mode 100644 index 0000000000000000000000000000000000000000..d348987d1655d376a7fc1396d8f9e48551006f6c --- /dev/null +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -0,0 +1,14 @@ +import { EOL } from "os" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.service.commands.restart, + Effect.fn("cli.service.restart")(function* () { + const daemon = yield* Daemon.Service + yield* daemon.stop() + process.stdout.write((yield* daemon.start()) + EOL) + }), +) diff --git a/packages/cli/src/commands/handlers/service/start.ts b/packages/cli/src/commands/handlers/service/start.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d6fbaada9fca59087cb9ca2c9837549a0dda0bf --- /dev/null +++ b/packages/cli/src/commands/handlers/service/start.ts @@ -0,0 +1,12 @@ +import { EOL } from "os" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.service.commands.start, + Effect.fn("cli.service.start")(function* () { + process.stdout.write((yield* (yield* Daemon.Service).start()) + EOL) + }), +) diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..d409970e8bcd1b998473ce083d9f1b618ec5de75 --- /dev/null +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -0,0 +1,13 @@ +import { EOL } from "os" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.service.commands.status, + Effect.fn("cli.service.status")(function* () { + const url = yield* (yield* Daemon.Service).status() + process.stdout.write((url ? `running ${url}` : "stopped") + EOL) + }), +) diff --git a/packages/cli/src/commands/handlers/service/stop.ts b/packages/cli/src/commands/handlers/service/stop.ts new file mode 100644 index 0000000000000000000000000000000000000000..8da9b04cffd5b2e8e893aecab117ff5140950caf --- /dev/null +++ b/packages/cli/src/commands/handlers/service/stop.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.service.commands.stop, + Effect.fn("cli.service.stop")(function* () { + yield* (yield* Daemon.Service).stop() + }), +) diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..97247e4d6b813dcf33be4a38c85601da61b70a1a --- /dev/null +++ b/packages/cli/src/framework/runtime.ts @@ -0,0 +1,79 @@ +import * as Effect from "effect/Effect" +import * as Command from "effect/unstable/cli/Command" +import { Spec } from "./spec" +import { Daemon } from "../services/daemon" + +export type Input = + Value extends Spec.Node + ? Input + : Value extends Command.Command + ? Input + : never + +type RuntimeHandler = (input: unknown) => Effect.Effect +type Loader = () => Promise<{ + default: (input: Input) => Effect.Effect +}> +type ProvidedCommand = Command.Command + +export type Handlers = keyof Node["commands"] extends never + ? Loader + : { readonly $?: Loader } & { readonly [Key in keyof Node["commands"]]: Handlers } + +interface LazyHandler { + readonly spec: Command.Command.Any + readonly load: () => Promise<{ default: RuntimeHandler }> +} + +type RuntimeHandlers = + | (() => Promise<{ default: RuntimeHandler }>) + | { + readonly $?: () => Promise<{ default: RuntimeHandler }> + readonly [key: string]: RuntimeHandlers | (() => Promise<{ default: RuntimeHandler }>) | undefined + } + +export function handler( + _node: Node, + run: (input: Input) => Effect.Effect, +) { + return run +} + +export function handlers(root: Root, handlers: Handlers) { + const result: LazyHandler[] = [] + + function add(node: Spec.Any, value: RuntimeHandlers) { + if (typeof value === "function") { + result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> }) + return + } + if (value.$) result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> }) + for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers) + } + + add(root, handlers as RuntimeHandlers) + return result +} + +export function run(commands: Spec.Any, handlers: ReadonlyArray, options: { readonly version: string }) { + return Command.run(provide(commands, handlers), options) as Effect.Effect +} + +function provide(node: Spec.Any, handlers: ReadonlyArray): ProvidedCommand { + const handler = handlers.find((handler) => handler.spec === node.spec) + const spec = handler + ? node.spec.pipe( + Command.withHandler((input) => + Effect.gen(function* () { + yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input)) + }), + ), + ) + : node.spec + if (!Object.keys(node.commands).length) return spec as ProvidedCommand + return spec.pipe( + Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))), + ) as ProvidedCommand +} + +export * as Runtime from "./runtime" diff --git a/packages/cli/src/framework/spec.ts b/packages/cli/src/framework/spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bb47e5e5edc99fc6422f62f3ad41febb209a3a9 --- /dev/null +++ b/packages/cli/src/framework/spec.ts @@ -0,0 +1,42 @@ +import * as Command from "effect/unstable/cli/Command" + +type Options> = { + readonly description?: string + readonly params?: Config + readonly commands?: Commands +} + +export interface Node< + Name extends string, + Spec extends Command.Command, + Commands extends Children, +> { + readonly name: Name + readonly spec: Spec + readonly commands: Commands +} + +export type Any = Node, Children> +export type Children = Readonly> + +export function make< + const Name extends string, + const Config extends Command.Command.Config = {}, + const Commands extends ReadonlyArray = [], +>(name: Name, options: Options = {}) { + const command = Command.make(name, options.params ?? ({} as Config)) + const spec = options.description ? command.pipe(Command.withDescription(options.description)) : command + return { + name, + spec, + commands: Object.fromEntries( + (options.commands ?? []).map((command) => [command.name, command]), + ) as ChildrenOf, + } +} + +type ChildrenOf> = { + readonly [Node in Commands[number] as Node["name"]]: Node +} + +export * as Spec from "./spec" diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b9303f7c35e81080443c793968fc382f6b8ac83 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env bun + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as Effect from "effect/Effect" +import { Commands } from "./commands/commands" +import { Runtime } from "./framework/runtime" +import { Daemon } from "./services/daemon" + +const Handlers = Runtime.handlers(Commands, { + $: () => import("./commands/handlers/default"), + api: () => import("./commands/handlers/api"), + debug: { + agents: () => import("./commands/handlers/debug/agents"), + }, + migrate: () => import("./commands/handlers/migrate"), + service: { + start: () => import("./commands/handlers/service/start"), + restart: () => import("./commands/handlers/service/restart"), + status: () => import("./commands/handlers/service/status"), + stop: () => import("./commands/handlers/service/stop"), + password: () => import("./commands/handlers/service/password"), + }, + serve: () => import("./commands/handlers/serve"), +}) + +Runtime.run(Commands, Handlers, { version: "local" }).pipe( + Effect.provide(Daemon.layer), + Effect.provide(NodeServices.layer), + Effect.scoped, + NodeRuntime.runMain, +) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd30656f55930454537997ecebb63a1c1853e889 --- /dev/null +++ b/packages/cli/src/services/daemon.ts @@ -0,0 +1,192 @@ +import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { ServerAuth } from "@opencode-ai/server/auth" +import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect" +import { HttpServer } from "effect/unstable/http" +import { randomBytes, randomUUID } from "crypto" +import { spawn } from "node:child_process" +import path from "path" + +export interface Interface { + readonly client: () => Effect.Effect, unknown> + readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown> + readonly start: () => Effect.Effect + readonly status: () => Effect.Effect + readonly stop: () => Effect.Effect + readonly password: (value?: string) => Effect.Effect + readonly register: (address: HttpServer.Address) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/cli/Daemon") {} + +const Registration = Schema.Struct({ + id: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + url: Schema.String, + pid: Schema.Int.check(Schema.isGreaterThan(0)), +}) +type Registration = typeof Registration.Type + +function sameRegistration(left: Registration, right: Registration) { + return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const directory = Global.Path.state + const file = path.join(directory, "server.json") + const passwordFile = path.join(directory, "password") + const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) + + const password = Effect.fn("cli.daemon.password")(function* (value?: string) { + const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (value === undefined && existing) return existing + + // Keep one private credential across server restarts so discovered clients + // can reconnect without exposing a password flag or environment variable. + const generated = value ?? randomBytes(32).toString("base64url") + const temp = passwordFile + ".tmp" + yield* fs.makeDirectory(directory, { recursive: true }) + yield* fs.writeFileString(temp, generated, { mode: 0o600 }) + yield* fs.rename(temp, passwordFile) + return generated + }) + + const registration = Effect.fnUntraced(function* () { + return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration)) + }) + + const createClient = Effect.fnUntraced(function* (url: string) { + return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) }) + }) + + const healthy = Effect.fnUntraced(function* () { + const info = yield* registration() + const client = yield* createClient(info.url) + const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) })) + if (response.data?.healthy === true) return info + return yield* Effect.fail(new Error("Registered server is not healthy")) + }) + + const compatible = Effect.fnUntraced(function* () { + const info = yield* healthy() + if (info.version === InstallationVersion) return info + return yield* Effect.fail(new Error("Registered server version does not match the client")) + }) + + const signal = (pid: number, signal: NodeJS.Signals) => + Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore) + + const awaitStopped = Effect.fnUntraced(function* (pid: number) { + const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe( + Effect.orElseSucceed(() => false), + ) + if (!running) return true + return yield* Effect.fail(new Error(`Server process ${pid} is still running`)) + }) + + const stopProcess = Effect.fnUntraced(function* (info: Registration) { + const current = yield* healthy().pipe(Effect.option) + if (Option.isNone(current) || !sameRegistration(current.value, info)) return + + yield* signal(info.pid, "SIGTERM") + const stopped = yield* awaitStopped(info.pid).pipe( + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), + Effect.option, + ) + if (Option.isSome(stopped)) return + + const latest = yield* healthy().pipe(Effect.option) + if (Option.isNone(latest) || !sameRegistration(latest.value, info)) return + yield* signal(info.pid, "SIGKILL") + yield* awaitStopped(info.pid).pipe( + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), + ) + }) + + const start = Effect.fn("cli.daemon.start")(function* () { + const existing = yield* healthy().pipe(Effect.option) + const found = Option.getOrUndefined(existing) + const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" + if (found?.version === InstallationVersion && compiled) return found.url + if (found) yield* stopProcess(found).pipe(Effect.ignore) + + const entrypoint = compiled ? undefined : process.argv[1] + if (!compiled && entrypoint === undefined) + return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint")) + yield* Effect.try({ + try: () => { + spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], { + detached: true, + stdio: "ignore", + }).unref() + }, + catch: (cause) => new Error("Failed to start server", { cause }), + }) + + return yield* compatible().pipe( + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), + Effect.map((info) => info.url), + Effect.mapError(() => new Error("Failed to start server")), + ) + }) + + const transport = Effect.fn("cli.daemon.transport")(function* () { + return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) } + }) + + const client = Effect.fn("cli.daemon.client")(function* () { + const connection = yield* transport() + return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers }) + }) + + const status = Effect.fn("cli.daemon.status")(function* () { + const existing = yield* healthy().pipe(Effect.option) + const found = Option.getOrUndefined(existing) + if (found?.version === InstallationVersion) return found.url + if (found) return undefined + yield* fs.remove(file).pipe(Effect.ignore) + return undefined + }) + + const stop = Effect.fn("cli.daemon.stop")(function* () { + const existing = yield* healthy().pipe(Effect.option) + // A stale registration may point at a PID that has since been reused by + // another process. Only signal the PID after authenticating the server. + if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore) + yield* stopProcess(existing.value) + yield* fs.remove(file).pipe(Effect.ignore) + }) + + const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) { + const id = randomUUID() + const temp = file + "." + id + ".tmp" + yield* fs.makeDirectory(directory, { recursive: true }) + yield* fs.writeFileString( + temp, + JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }), + { mode: 0o600 }, + ) + yield* fs.rename(temp, file) + yield* registration().pipe( + Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))), + Effect.catch(() => signal(process.pid, "SIGTERM")), + Effect.repeat(Schedule.spaced("10 seconds")), + Effect.forkScoped, + ) + yield* Effect.addFinalizer(() => + registration().pipe( + Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)), + Effect.ignore, + ), + ) + }) + + return Service.of({ client, transport, start, status, stop, password, register }) + }), +) + +export * as Daemon from "./daemon" diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts new file mode 100644 index 0000000000000000000000000000000000000000..5100e1c99ac32b9a8f43221db175dd5032d7e706 --- /dev/null +++ b/packages/cli/src/tui.ts @@ -0,0 +1,37 @@ +import { run } from "@opencode-ai/tui" +import { TuiConfig } from "@opencode-ai/tui/config" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" + +export function runTui(transport: { url: string; headers: RequestInit["headers"] }) { + const config = TuiConfig.resolve({}, { terminalSuspend: false }) + return run({ + ...transport, + args: {}, + config, + fetch: gracefulFetch, + pluginHost: { + async start() {}, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) +} + +const legacyDefaults: Record = { + "/config/providers": { providers: [], default: {} }, + "/provider": { all: [], default: {}, connected: [] }, + "/agent": [], + "/config": {}, +} + +const gracefulFetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await fetch(input, init) + if (response.status !== 404) return response + const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname] + if (fallback === undefined) return response + return Response.json(fallback) + }, + { preconnect: fetch.preconnect }, +) diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts new file mode 100644 index 0000000000000000000000000000000000000000..aeec4b3e34512e3a39172abdc79581d3d8095d34 --- /dev/null +++ b/packages/client/script/build.ts @@ -0,0 +1,30 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" +import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract" +import { Effect } from "effect" +import { fileURLToPath } from "url" + +const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints }) + +await Effect.runPromise( + Effect.all( + [ + write( + emitPromise(contract, { + outputTypes: { + "events.subscribe": { + name: "OpenCodeEventEncoded", + import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"', + }, + }, + }), + fileURLToPath(new URL("../src/generated", import.meta.url)), + ), + write( + emitEffectImported(contract, { module: "../contract", api: "ClientApi" }), + fileURLToPath(new URL("../src/generated-effect", import.meta.url)), + ), + ], + { concurrency: 2, discard: true }, + ).pipe(Effect.provide(NodeFileSystem.layer)), +) diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts new file mode 100644 index 0000000000000000000000000000000000000000..413fea9dc338b345d40e1dc4b4cf7a962eba1376 --- /dev/null +++ b/packages/client/src/contract.ts @@ -0,0 +1,53 @@ +import { makeDefaultApi } from "@opencode-ai/protocol/api" +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/LocationMiddleware", +) {} + +class SessionLocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/SessionLocationMiddleware", + { error: [InvalidRequestError, SessionNotFoundError] }, +) {} + +export const ClientApi = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) + +export const groupNames = { + "server.health": "health", + "server.location": "location", + "server.agent": "agents", + "server.session": "sessions", + "server.message": "messages", + "server.model": "models", + "server.provider": "providers", + "server.integration": "integrations", + "server.credential": "credentials", + "server.permission": "permissions", + "server.fs": "files", + "server.command": "commands", + "server.skill": "skills", + "server.event": "events", + "server.pty": "ptys", + "server.question": "questions", + "server.reference": "references", + "server.projectCopy": "projectCopies", +} as const + +export const endpointNames = { + "session.messages": "list", + "integration.connect.key": "connectKey", + "integration.connect.oauth": "connectOauth", + "integration.attempt.status": "attemptStatus", + "integration.attempt.complete": "attemptComplete", + "integration.attempt.cancel": "attemptCancel", + "permission.request.list": "listRequests", + "permission.saved.list": "listSaved", + "permission.saved.remove": "removeSaved", + "question.request.list": "listRequests", +} as const + +export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts new file mode 100644 index 0000000000000000000000000000000000000000..b580c7f48acf0487ed9c67c6103aee10908e29ff --- /dev/null +++ b/packages/client/src/effect.ts @@ -0,0 +1,25 @@ +// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import +// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. +export * from "./generated-effect/index" +export { Agent } from "@opencode-ai/schema/agent" +export { Command } from "@opencode-ai/schema/command" +export { Credential } from "@opencode-ai/schema/credential" +export { FileSystem } from "@opencode-ai/schema/filesystem" +export { Integration } from "@opencode-ai/schema/integration" +export { Location } from "@opencode-ai/schema/location" +export { Model } from "@opencode-ai/schema/model" +export { Permission } from "@opencode-ai/schema/permission" +export { PermissionSaved } from "@opencode-ai/schema/permission-saved" +export { Project } from "@opencode-ai/schema/project" +export { ProjectCopy } from "@opencode-ai/schema/project-copy" +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 { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" +export { Session } from "@opencode-ai/schema/session" +export { SessionInput } from "@opencode-ai/schema/session-input" +export { SessionMessage } from "@opencode-ai/schema/session-message" +export { Skill } from "@opencode-ai/schema/skill" +export { Prompt } from "@opencode-ai/schema/prompt" +export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json new file mode 100644 index 0000000000000000000000000000000000000000..958eb566dbd5551248bc6248a8d72e3613772f2a --- /dev/null +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -0,0 +1,5 @@ +[ + "client-error.ts", + "client.ts", + "index.ts" +] diff --git a/packages/client/src/generated-effect/client-error.ts b/packages/client/src/generated-effect/client-error.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcc65d9bdd208f4ceea214feaa1e048fa1821f38 --- /dev/null +++ b/packages/client/src/generated-effect/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..024c9782806c127182f3d25cc626cfb90a428003 --- /dev/null +++ b/packages/client/src/generated-effect/client.ts @@ -0,0 +1,706 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Stream, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient } from "effect/unstable/httpapi" +import { ClientApi } from "../contract" +import { ClientError } from "./client-error" + +type RawClient = HttpApiClient.ForApi + +const mapClientError = (error: E) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : error + +const Endpoint0_0 = (raw: RawClient["server.health"]) => () => + raw["health.get"]({}).pipe(Effect.mapError(mapClientError)) + +const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) }) + +type Endpoint1_0Request = Parameters[0] +type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] } +const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) => + raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) }) + +type Endpoint2_0Request = Parameters[0] +type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } +const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) => + raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) }) + +type Endpoint3_0Request = Parameters[0] +type Endpoint3_0Input = { + readonly workspace?: Endpoint3_0Request["query"]["workspace"] + readonly limit?: Endpoint3_0Request["query"]["limit"] + readonly order?: Endpoint3_0Request["query"]["order"] + readonly search?: Endpoint3_0Request["query"]["search"] + readonly directory?: Endpoint3_0Request["query"]["directory"] + readonly project?: Endpoint3_0Request["query"]["project"] + readonly subpath?: Endpoint3_0Request["query"]["subpath"] + readonly cursor?: Endpoint3_0Request["query"]["cursor"] +} +const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) => + raw["session.list"]({ + query: { + workspace: input?.["workspace"], + limit: input?.["limit"], + order: input?.["order"], + search: input?.["search"], + directory: input?.["directory"], + project: input?.["project"], + subpath: input?.["subpath"], + cursor: input?.["cursor"], + }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_1Request = Parameters[0] +type Endpoint3_1Input = { + readonly id?: Endpoint3_1Request["payload"]["id"] + readonly agent?: Endpoint3_1Request["payload"]["agent"] + readonly model?: Endpoint3_1Request["payload"]["model"] + readonly location?: Endpoint3_1Request["payload"]["location"] +} +const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) => + raw["session.create"]({ + payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +const Endpoint3_2 = (raw: RawClient["server.session"]) => () => + raw["session.active"]({}).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_3Request = Parameters[0] +type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] } +const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) => + raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_4Request = Parameters[0] +type Endpoint3_4Input = { + readonly sessionID: Endpoint3_4Request["params"]["sessionID"] + readonly agent: Endpoint3_4Request["payload"]["agent"] +} +const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) => + raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint3_5Request = Parameters[0] +type Endpoint3_5Input = { + readonly sessionID: Endpoint3_5Request["params"]["sessionID"] + readonly model: Endpoint3_5Request["payload"]["model"] +} +const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) => + raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint3_6Request = Parameters[0] +type Endpoint3_6Input = { + readonly sessionID: Endpoint3_6Request["params"]["sessionID"] + readonly id?: Endpoint3_6Request["payload"]["id"] + readonly prompt: Endpoint3_6Request["payload"]["prompt"] + readonly delivery?: Endpoint3_6Request["payload"]["delivery"] + readonly resume?: Endpoint3_6Request["payload"]["resume"] +} +const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) => + raw["session.prompt"]({ + params: { sessionID: input["sessionID"] }, + payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_7Request = Parameters[0] +type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] } +const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_8Request = Parameters[0] +type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] } +const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) => + raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_9Request = Parameters[0] +type Endpoint3_9Input = { + readonly sessionID: Endpoint3_9Request["params"]["sessionID"] + readonly messageID: Endpoint3_9Request["payload"]["messageID"] + readonly files?: Endpoint3_9Request["payload"]["files"] +} +const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => + raw["session.revert.stage"]({ + params: { sessionID: input["sessionID"] }, + payload: { messageID: input["messageID"], files: input["files"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_10Request = Parameters[0] +type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } +const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_11Request = Parameters[0] +type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } +const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_12Request = Parameters[0] +type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] } +const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => + raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_13Request = Parameters[0] +type Endpoint3_13Input = { + readonly sessionID: Endpoint3_13Request["params"]["sessionID"] + readonly limit?: Endpoint3_13Request["query"]["limit"] + readonly after?: Endpoint3_13Request["query"]["after"] +} +const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => + raw["session.history"]({ + params: { sessionID: input["sessionID"] }, + query: { limit: input["limit"], after: input["after"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_14Request = Parameters[0] +type Endpoint3_14Input = { + readonly sessionID: Endpoint3_14Request["params"]["sessionID"] + readonly after?: Endpoint3_14Request["query"]["after"] +} +const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => + Stream.unwrap( + raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), + ), + ) + +type Endpoint3_15Request = Parameters[0] +type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] } +const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_16Request = Parameters[0] +type Endpoint3_16Input = { + readonly sessionID: Endpoint3_16Request["params"]["sessionID"] + readonly messageID: Endpoint3_16Request["params"]["messageID"] +} +const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => + raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +const adaptGroup3 = (raw: RawClient["server.session"]) => ({ + list: Endpoint3_0(raw), + create: Endpoint3_1(raw), + active: Endpoint3_2(raw), + get: Endpoint3_3(raw), + switchAgent: Endpoint3_4(raw), + switchModel: Endpoint3_5(raw), + prompt: Endpoint3_6(raw), + compact: Endpoint3_7(raw), + wait: Endpoint3_8(raw), + stage: Endpoint3_9(raw), + clear: Endpoint3_10(raw), + commit: Endpoint3_11(raw), + context: Endpoint3_12(raw), + history: Endpoint3_13(raw), + events: Endpoint3_14(raw), + interrupt: Endpoint3_15(raw), + message: Endpoint3_16(raw), +}) + +type Endpoint4_0Request = Parameters[0] +type Endpoint4_0Input = { + readonly sessionID: Endpoint4_0Request["params"]["sessionID"] + readonly limit?: Endpoint4_0Request["query"]["limit"] + readonly order?: Endpoint4_0Request["query"]["order"] + readonly cursor?: Endpoint4_0Request["query"]["cursor"] +} +const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) => + raw["session.messages"]({ + params: { sessionID: input["sessionID"] }, + query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) }) + +type Endpoint5_0Request = Parameters[0] +type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] } +const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) => + raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) }) + +type Endpoint6_0Request = Parameters[0] +type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] } +const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) => + raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint6_1Request = Parameters[0] +type Endpoint6_1Input = { + readonly providerID: Endpoint6_1Request["params"]["providerID"] + readonly location?: Endpoint6_1Request["query"]["location"] +} +const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) => + raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) }) + +type Endpoint7_0Request = Parameters[0] +type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] } +const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) => + raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_1Request = Parameters[0] +type Endpoint7_1Input = { + readonly integrationID: Endpoint7_1Request["params"]["integrationID"] + readonly location?: Endpoint7_1Request["query"]["location"] +} +const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) => + raw["integration.get"]({ + params: { integrationID: input["integrationID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_2Request = Parameters[0] +type Endpoint7_2Input = { + readonly integrationID: Endpoint7_2Request["params"]["integrationID"] + readonly location?: Endpoint7_2Request["query"]["location"] + readonly key: Endpoint7_2Request["payload"]["key"] + readonly label?: Endpoint7_2Request["payload"]["label"] +} +const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) => + raw["integration.connect.key"]({ + params: { integrationID: input["integrationID"] }, + query: { location: input["location"] }, + payload: { key: input["key"], label: input["label"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_3Request = Parameters[0] +type Endpoint7_3Input = { + readonly integrationID: Endpoint7_3Request["params"]["integrationID"] + readonly location?: Endpoint7_3Request["query"]["location"] + readonly methodID: Endpoint7_3Request["payload"]["methodID"] + readonly inputs: Endpoint7_3Request["payload"]["inputs"] + readonly label?: Endpoint7_3Request["payload"]["label"] +} +const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) => + raw["integration.connect.oauth"]({ + params: { integrationID: input["integrationID"] }, + query: { location: input["location"] }, + payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_4Request = Parameters[0] +type Endpoint7_4Input = { + readonly attemptID: Endpoint7_4Request["params"]["attemptID"] + readonly location?: Endpoint7_4Request["query"]["location"] +} +const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) => + raw["integration.attempt.status"]({ + params: { attemptID: input["attemptID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_5Request = Parameters[0] +type Endpoint7_5Input = { + readonly attemptID: Endpoint7_5Request["params"]["attemptID"] + readonly location?: Endpoint7_5Request["query"]["location"] + readonly code?: Endpoint7_5Request["payload"]["code"] +} +const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) => + raw["integration.attempt.complete"]({ + params: { attemptID: input["attemptID"] }, + query: { location: input["location"] }, + payload: { code: input["code"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint7_6Request = Parameters[0] +type Endpoint7_6Input = { + readonly attemptID: Endpoint7_6Request["params"]["attemptID"] + readonly location?: Endpoint7_6Request["query"]["location"] +} +const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) => + raw["integration.attempt.cancel"]({ + params: { attemptID: input["attemptID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup7 = (raw: RawClient["server.integration"]) => ({ + list: Endpoint7_0(raw), + get: Endpoint7_1(raw), + connectKey: Endpoint7_2(raw), + connectOauth: Endpoint7_3(raw), + attemptStatus: Endpoint7_4(raw), + attemptComplete: Endpoint7_5(raw), + attemptCancel: Endpoint7_6(raw), +}) + +type Endpoint8_0Request = Parameters[0] +type Endpoint8_0Input = { + readonly credentialID: Endpoint8_0Request["params"]["credentialID"] + readonly location?: Endpoint8_0Request["query"]["location"] + readonly label: Endpoint8_0Request["payload"]["label"] +} +const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) => + raw["credential.update"]({ + params: { credentialID: input["credentialID"] }, + query: { location: input["location"] }, + payload: { label: input["label"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint8_1Request = Parameters[0] +type Endpoint8_1Input = { + readonly credentialID: Endpoint8_1Request["params"]["credentialID"] + readonly location?: Endpoint8_1Request["query"]["location"] +} +const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) => + raw["credential.remove"]({ + params: { credentialID: input["credentialID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) }) + +type Endpoint9_0Request = Parameters[0] +type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } +const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) => + raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint9_1Request = Parameters[0] +type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] } +const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) => + raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint9_2Request = Parameters[0] +type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] } +const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) => + raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint9_3Request = Parameters[0] +type Endpoint9_3Input = { + readonly sessionID: Endpoint9_3Request["params"]["sessionID"] + readonly id?: Endpoint9_3Request["payload"]["id"] + readonly action: Endpoint9_3Request["payload"]["action"] + readonly resources: Endpoint9_3Request["payload"]["resources"] + readonly save?: Endpoint9_3Request["payload"]["save"] + readonly metadata?: Endpoint9_3Request["payload"]["metadata"] + readonly source?: Endpoint9_3Request["payload"]["source"] + readonly agent?: Endpoint9_3Request["payload"]["agent"] +} +const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) => + raw["session.permission.create"]({ + params: { sessionID: input["sessionID"] }, + payload: { + id: input["id"], + action: input["action"], + resources: input["resources"], + save: input["save"], + metadata: input["metadata"], + source: input["source"], + agent: input["agent"], + }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint9_4Request = Parameters[0] +type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] } +const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) => + raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint9_5Request = Parameters[0] +type Endpoint9_5Input = { + readonly sessionID: Endpoint9_5Request["params"]["sessionID"] + readonly requestID: Endpoint9_5Request["params"]["requestID"] +} +const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) => + raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint9_6Request = Parameters[0] +type Endpoint9_6Input = { + readonly sessionID: Endpoint9_6Request["params"]["sessionID"] + readonly requestID: Endpoint9_6Request["params"]["requestID"] + readonly reply: Endpoint9_6Request["payload"]["reply"] + readonly message?: Endpoint9_6Request["payload"]["message"] +} +const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) => + raw["session.permission.reply"]({ + params: { sessionID: input["sessionID"], requestID: input["requestID"] }, + payload: { reply: input["reply"], message: input["message"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup9 = (raw: RawClient["server.permission"]) => ({ + listRequests: Endpoint9_0(raw), + listSaved: Endpoint9_1(raw), + removeSaved: Endpoint9_2(raw), + create: Endpoint9_3(raw), + list: Endpoint9_4(raw), + get: Endpoint9_5(raw), + reply: Endpoint9_6(raw), +}) + +type Endpoint10_0Request = Parameters[0] +type Endpoint10_0Input = { + readonly location?: Endpoint10_0Request["query"]["location"] + readonly path?: Endpoint10_0Request["query"]["path"] +} +const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) => + raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { + readonly location?: Endpoint10_1Request["query"]["location"] + readonly query: Endpoint10_1Request["query"]["query"] + readonly type?: Endpoint10_1Request["query"]["type"] + readonly limit?: Endpoint10_1Request["query"]["limit"] +} +const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) => + raw["fs.find"]({ + query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) }) + +type Endpoint11_0Request = Parameters[0] +type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) => + raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) }) + +type Endpoint12_0Request = Parameters[0] +type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } +const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) => + raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) }) + +const Endpoint13_0 = (raw: RawClient["server.event"]) => () => + Stream.unwrap( + raw["event.subscribe"]({}).pipe( + Effect.mapError(mapClientError), + Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), + ), + ) + +const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) }) + +type Endpoint14_0Request = Parameters[0] +type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) => + raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint14_1Request = Parameters[0] +type Endpoint14_1Input = { + readonly location?: Endpoint14_1Request["query"]["location"] + readonly command?: Endpoint14_1Request["payload"]["command"] + readonly args?: Endpoint14_1Request["payload"]["args"] + readonly cwd?: Endpoint14_1Request["payload"]["cwd"] + readonly title?: Endpoint14_1Request["payload"]["title"] + readonly env?: Endpoint14_1Request["payload"]["env"] +} +const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) => + raw["pty.create"]({ + query: { location: input?.["location"] }, + payload: { + command: input?.["command"], + args: input?.["args"], + cwd: input?.["cwd"], + title: input?.["title"], + env: input?.["env"], + }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint14_2Request = Parameters[0] +type Endpoint14_2Input = { + readonly ptyID: Endpoint14_2Request["params"]["ptyID"] + readonly location?: Endpoint14_2Request["query"]["location"] +} +const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) => + raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint14_3Request = Parameters[0] +type Endpoint14_3Input = { + readonly ptyID: Endpoint14_3Request["params"]["ptyID"] + readonly location?: Endpoint14_3Request["query"]["location"] + readonly title?: Endpoint14_3Request["payload"]["title"] + readonly size?: Endpoint14_3Request["payload"]["size"] +} +const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) => + raw["pty.update"]({ + params: { ptyID: input["ptyID"] }, + query: { location: input["location"] }, + payload: { title: input["title"], size: input["size"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint14_4Request = Parameters[0] +type Endpoint14_4Input = { + readonly ptyID: Endpoint14_4Request["params"]["ptyID"] + readonly location?: Endpoint14_4Request["query"]["location"] +} +const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) => + raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +const adaptGroup14 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint14_0(raw), + create: Endpoint14_1(raw), + get: Endpoint14_2(raw), + update: Endpoint14_3(raw), + remove: Endpoint14_4(raw), +}) + +type Endpoint15_0Request = Parameters[0] +type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } +const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) => + raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint15_1Request = Parameters[0] +type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] } +const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) => + raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint15_2Request = Parameters[0] +type Endpoint15_2Input = { + readonly sessionID: Endpoint15_2Request["params"]["sessionID"] + readonly requestID: Endpoint15_2Request["params"]["requestID"] + readonly answers: Endpoint15_2Request["payload"]["answers"] +} +const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) => + raw["session.question.reply"]({ + params: { sessionID: input["sessionID"], requestID: input["requestID"] }, + payload: { answers: input["answers"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint15_3Request = Parameters[0] +type Endpoint15_3Input = { + readonly sessionID: Endpoint15_3Request["params"]["sessionID"] + readonly requestID: Endpoint15_3Request["params"]["requestID"] +} +const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) => + raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( + Effect.mapError(mapClientError), + ) + +const adaptGroup15 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint15_0(raw), + list: Endpoint15_1(raw), + reply: Endpoint15_2(raw), + reject: Endpoint15_3(raw), +}) + +type Endpoint16_0Request = Parameters[0] +type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } +const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) => + raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) }) + +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { + readonly projectID: Endpoint17_0Request["params"]["projectID"] + readonly location?: Endpoint17_0Request["query"]["location"] + readonly strategy: Endpoint17_0Request["payload"]["strategy"] + readonly directory: Endpoint17_0Request["payload"]["directory"] + readonly name?: Endpoint17_0Request["payload"]["name"] +} +const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) => + raw["projectCopy.create"]({ + params: { projectID: input["projectID"] }, + query: { location: input["location"] }, + payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint17_1Request = Parameters[0] +type Endpoint17_1Input = { + readonly projectID: Endpoint17_1Request["params"]["projectID"] + readonly location?: Endpoint17_1Request["query"]["location"] + readonly directory: Endpoint17_1Request["payload"]["directory"] + readonly force: Endpoint17_1Request["payload"]["force"] +} +const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) => + raw["projectCopy.remove"]({ + params: { projectID: input["projectID"] }, + query: { location: input["location"] }, + payload: { directory: input["directory"], force: input["force"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint17_2Request = Parameters[0] +type Endpoint17_2Input = { + readonly projectID: Endpoint17_2Request["params"]["projectID"] + readonly location?: Endpoint17_2Request["query"]["location"] +} +const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) => + raw["projectCopy.refresh"]({ + params: { projectID: input["projectID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint17_0(raw), + remove: Endpoint17_1(raw), + refresh: Endpoint17_2(raw), +}) + +const adaptClient = (raw: RawClient) => ({ + health: adaptGroup0(raw["server.health"]), + location: adaptGroup1(raw["server.location"]), + agents: adaptGroup2(raw["server.agent"]), + sessions: adaptGroup3(raw["server.session"]), + messages: adaptGroup4(raw["server.message"]), + models: adaptGroup5(raw["server.model"]), + providers: adaptGroup6(raw["server.provider"]), + integrations: adaptGroup7(raw["server.integration"]), + credentials: adaptGroup8(raw["server.credential"]), + permissions: adaptGroup9(raw["server.permission"]), + files: adaptGroup10(raw["server.fs"]), + commands: adaptGroup11(raw["server.command"]), + skills: adaptGroup12(raw["server.skill"]), + events: adaptGroup13(raw["server.event"]), + ptys: adaptGroup14(raw["server.pty"]), + questions: adaptGroup15(raw["server.question"]), + references: adaptGroup16(raw["server.reference"]), + projectCopies: adaptGroup17(raw["server.projectCopy"]), +}) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient)) diff --git a/packages/client/src/generated-effect/index.ts b/packages/client/src/generated-effect/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc0dbc9fa4df8f555aecceb70252d830a74394ad --- /dev/null +++ b/packages/client/src/generated-effect/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json new file mode 100644 index 0000000000000000000000000000000000000000..25700fc72da2a0c2043865dba346bbf4fe7828d1 --- /dev/null +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -0,0 +1,6 @@ +[ + "client-error.ts", + "client.ts", + "index.ts", + "types.ts" +] diff --git a/packages/client/src/generated/client-error.ts b/packages/client/src/generated/client-error.ts new file mode 100644 index 0000000000000000000000000000000000000000..c278f0ddc806e3b47ff66f9d6d4b12095d73aaca --- /dev/null +++ b/packages/client/src/generated/client-error.ts @@ -0,0 +1,11 @@ +export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse" + +export class ClientError extends Error { + override readonly name = "ClientError" + constructor( + readonly reason: ClientErrorReason, + options?: ErrorOptions, + ) { + super(reason, options) + } +} diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..27ec3d81ba2c050574feb0b9bbd37d2969c492cd --- /dev/null +++ b/packages/client/src/generated/client.ts @@ -0,0 +1,1029 @@ +import type { + HealthGetOutput, + LocationGetInput, + LocationGetOutput, + AgentsListInput, + AgentsListOutput, + SessionsListInput, + SessionsListOutput, + SessionsCreateInput, + SessionsCreateOutput, + SessionsActiveOutput, + SessionsGetInput, + SessionsGetOutput, + SessionsSwitchAgentInput, + SessionsSwitchAgentOutput, + SessionsSwitchModelInput, + SessionsSwitchModelOutput, + SessionsPromptInput, + SessionsPromptOutput, + SessionsCompactInput, + SessionsCompactOutput, + SessionsWaitInput, + SessionsWaitOutput, + SessionsStageInput, + SessionsStageOutput, + SessionsClearInput, + SessionsClearOutput, + SessionsCommitInput, + SessionsCommitOutput, + SessionsContextInput, + SessionsContextOutput, + SessionsHistoryInput, + SessionsHistoryOutput, + SessionsEventsInput, + SessionsEventsOutput, + SessionsInterruptInput, + SessionsInterruptOutput, + SessionsMessageInput, + SessionsMessageOutput, + MessagesListInput, + MessagesListOutput, + ModelsListInput, + ModelsListOutput, + ProvidersListInput, + ProvidersListOutput, + ProvidersGetInput, + ProvidersGetOutput, + IntegrationsListInput, + IntegrationsListOutput, + IntegrationsGetInput, + IntegrationsGetOutput, + IntegrationsConnectKeyInput, + IntegrationsConnectKeyOutput, + IntegrationsConnectOauthInput, + IntegrationsConnectOauthOutput, + IntegrationsAttemptStatusInput, + IntegrationsAttemptStatusOutput, + IntegrationsAttemptCompleteInput, + IntegrationsAttemptCompleteOutput, + IntegrationsAttemptCancelInput, + IntegrationsAttemptCancelOutput, + CredentialsUpdateInput, + CredentialsUpdateOutput, + CredentialsRemoveInput, + CredentialsRemoveOutput, + PermissionsListRequestsInput, + PermissionsListRequestsOutput, + PermissionsListSavedInput, + PermissionsListSavedOutput, + PermissionsRemoveSavedInput, + PermissionsRemoveSavedOutput, + PermissionsCreateInput, + PermissionsCreateOutput, + PermissionsListInput, + PermissionsListOutput, + PermissionsGetInput, + PermissionsGetOutput, + PermissionsReplyInput, + PermissionsReplyOutput, + FilesListInput, + FilesListOutput, + FilesFindInput, + FilesFindOutput, + CommandsListInput, + CommandsListOutput, + SkillsListInput, + SkillsListOutput, + EventsSubscribeOutput, + PtysListInput, + PtysListOutput, + PtysCreateInput, + PtysCreateOutput, + PtysGetInput, + PtysGetOutput, + PtysUpdateInput, + PtysUpdateOutput, + PtysRemoveInput, + PtysRemoveOutput, + QuestionsListRequestsInput, + QuestionsListRequestsOutput, + QuestionsListInput, + QuestionsListOutput, + QuestionsReplyInput, + QuestionsReplyOutput, + QuestionsRejectInput, + QuestionsRejectOutput, + ReferencesListInput, + ReferencesListOutput, + ProjectCopiesCreateInput, + ProjectCopiesCreateOutput, + ProjectCopiesRemoveInput, + ProjectCopiesRemoveOutput, + ProjectCopiesRefreshInput, + ProjectCopiesRefreshOutput, +} from "./types" +import { ClientError } from "./client-error" + +export interface ClientOptions { + readonly baseUrl: string + readonly fetch?: typeof globalThis.fetch + readonly headers?: HeadersInit +} + +export interface RequestOptions { + readonly signal?: AbortSignal + readonly headers?: HeadersInit +} + +interface RequestDescriptor { + readonly method: string + readonly path: string + readonly query?: Record + readonly headers?: Record + readonly body?: unknown + readonly successStatus: number + readonly declaredStatuses: ReadonlyArray + readonly empty: boolean +} + +export function make(options: ClientOptions) { + const fetch = options.fetch ?? globalThis.fetch + + const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { + const url = new URL(descriptor.path, options.baseUrl) + for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value) + const headers = new Headers(options.headers) + for (const [key, value] of Object.entries(descriptor.headers ?? {})) { + if (value !== undefined && value !== null) headers.set(key, String(value)) + } + for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value) + if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") + return { + url, + init: { + method: descriptor.method, + signal: requestOptions?.signal, + headers, + body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body), + } satisfies RequestInit, + } + } + + const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { + try { + const prepared = prepare(descriptor, requestOptions) + return await fetch(prepared.url, prepared.init) + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + } + + const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => { + if (descriptor.declaredStatuses.includes(response.status)) throw await json(response) + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnexpectedStatus", { cause: { status: response.status } }) + } + + const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { + const response = await execute(descriptor, requestOptions) + if (response.status !== descriptor.successStatus) return responseError(response, descriptor) + if (descriptor.empty) { + try { + await response.body?.cancel() + } catch {} + return undefined as A + } + return (await json(response)) as A + } + + const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + const response = await execute(descriptor, requestOptions) + if (response.status !== descriptor.successStatus) await responseError(response, descriptor) + if (!isContentType(response, "text/event-stream")) { + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnsupportedContentType") + } + if (response.body === null) throw new ClientError("MalformedResponse") + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + let next + try { + next = await reader.read() + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + buffer += decoder.decode(next.value, { stream: !next.done }) + if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse") + const trailingCarriageReturn = !next.done && buffer.endsWith("\r") + if (trailingCarriageReturn) buffer = buffer.slice(0, -1) + buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n") + if (trailingCarriageReturn) buffer += "\r" + if (next.done && buffer !== "") buffer += "\n\n" + let boundary = buffer.indexOf("\n\n") + while (boundary >= 0) { + const block = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + const data = block + .split("\n") + .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : [])) + .join("\n") + if (data !== "") { + try { + yield JSON.parse(data) as A + } catch (cause) { + throw new ClientError("MalformedResponse", { cause }) + } + } + boundary = buffer.indexOf("\n\n") + } + if (next.done) return + } + } finally { + try { + await reader.cancel() + } catch {} + reader.releaseLock() + } + }, + }) + + return { + health: { + get: (requestOptions?: RequestOptions) => + request( + { method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), + }, + location: { + get: (input?: LocationGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/location`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + agents: { + list: (input?: AgentsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/agent`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + sessions: { + list: (input?: SessionsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session`, + query: { + workspace: input?.["workspace"], + limit: input?.["limit"], + order: input?.["order"], + search: input?.["search"], + directory: input?.["directory"], + project: input?.["project"], + subpath: input?.["subpath"], + cursor: input?.["cursor"], + }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsCreateOutput }>( + { + method: "POST", + path: `/api/session`, + body: { + id: input?.["id"], + agent: input?.["agent"], + model: input?.["model"], + location: input?.["location"], + }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + active: (requestOptions?: RequestOptions) => + request<{ readonly data: SessionsActiveOutput }>( + { + method: "GET", + path: `/api/session/active`, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + get: (input: SessionsGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsGetOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, + body: { agent: input["agent"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, + body: { model: input["model"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsPromptOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, + body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, + successStatus: 200, + declaredStatuses: [409, 404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, + successStatus: 204, + declaredStatuses: [404, 503, 400, 401], + empty: true, + }, + requestOptions, + ), + wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`, + successStatus: 204, + declaredStatuses: [404, 503, 400, 401], + empty: true, + }, + requestOptions, + ), + stage: (input: SessionsStageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsStageOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, + body: { messageID: input["messageID"], files: input["files"] }, + successStatus: 200, + declaredStatuses: [404, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + clear: (input: SessionsClearInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, + successStatus: 204, + declaredStatuses: [404, 500, 400, 401], + empty: true, + }, + requestOptions, + ), + commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + context: (input: SessionsContextInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsContextOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/context`, + successStatus: 200, + declaredStatuses: [404, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/history`, + query: { limit: input["limit"], after: input["after"] }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ), + events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable => + sse( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/event`, + query: { after: input["after"] }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ), + interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + message: (input: SessionsMessageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsMessageOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + }, + messages: { + list: (input: MessagesListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/message`, + query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] }, + successStatus: 200, + declaredStatuses: [400, 404, 500, 401], + empty: false, + }, + requestOptions, + ), + }, + models: { + list: (input?: ModelsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/model`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [503, 401, 400], + empty: false, + }, + requestOptions, + ), + }, + providers: { + list: (input?: ProvidersListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/provider`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [503, 401, 400], + empty: false, + }, + requestOptions, + ), + get: (input: ProvidersGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/provider/${encodeURIComponent(input.providerID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [404, 503, 401, 400], + empty: false, + }, + requestOptions, + ), + }, + integrations: { + list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration/${encodeURIComponent(input.integrationID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, + query: { location: input["location"] }, + body: { key: input["key"], label: input["label"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, + query: { location: input["location"] }, + body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, + query: { location: input["location"] }, + body: { code: input["code"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, + credentials: { + update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) => + request( + { + method: "PATCH", + path: `/api/credential/${encodeURIComponent(input.credentialID)}`, + query: { location: input["location"] }, + body: { label: input["label"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/credential/${encodeURIComponent(input.credentialID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, + permissions: { + listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/permission/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionsListSavedOutput }>( + { + method: "GET", + path: `/api/permission/saved`, + query: { projectID: input?.["projectID"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/permission/saved/${encodeURIComponent(input.id)}`, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionsCreateOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`, + body: { + id: input["id"], + action: input["action"], + resources: input["resources"], + save: input["save"], + metadata: input["metadata"], + source: input["source"], + agent: input["agent"], + }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + list: (input: PermissionsListInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionsListOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + get: (input: PermissionsGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionsGetOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`, + body: { reply: input["reply"], message: input["message"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + }, + files: { + list: (input?: FilesListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/fs/list`, + query: { location: input?.["location"], path: input?.["path"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + find: (input: FilesFindInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/fs/find`, + query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + commands: { + list: (input?: CommandsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/command`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + skills: { + list: (input?: SkillsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/skill`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + events: { + subscribe: (requestOptions?: RequestOptions): AsyncIterable => + sse( + { method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), + }, + ptys: { + list: (input?: PtysListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/pty`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + create: (input?: PtysCreateInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/pty`, + query: { location: input?.["location"] }, + body: { + command: input?.["command"], + args: input?.["args"], + cwd: input?.["cwd"], + title: input?.["title"], + env: input?.["env"], + }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + get: (input: PtysGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/pty/${encodeURIComponent(input.ptyID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), + update: (input: PtysUpdateInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/pty/${encodeURIComponent(input.ptyID)}`, + query: { location: input["location"] }, + body: { title: input["title"], size: input["size"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), + remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/pty/${encodeURIComponent(input.ptyID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [404, 401, 400], + empty: true, + }, + requestOptions, + ), + }, + questions: { + listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/question/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + list: (input: QuestionsListInput, requestOptions?: RequestOptions) => + request<{ readonly data: QuestionsListOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/question`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`, + body: { answers: input["answers"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + }, + references: { + list: (input?: ReferencesListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/reference`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + projectCopies: { + create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`, + query: { location: input["location"] }, + body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`, + query: { location: input["location"] }, + body: { directory: input["directory"], force: input["force"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + }, + } +} + +function appendQuery(params: URLSearchParams, key: string, value: unknown): void { + if (value === undefined || value === null) return + if (Array.isArray(value)) { + for (const item of value) appendQuery(params, key, item) + return + } + if (typeof value === "object") { + for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item) + return + } + params.append(key, String(value)) +} + +async function json(response: Response): Promise { + if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) { + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnsupportedContentType") + } + let text: string + try { + text = await response.text() + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + if (text === "") throw new ClientError("MalformedResponse") + try { + return JSON.parse(text) + } catch (cause) { + throw new ClientError("MalformedResponse", { cause }) + } +} + +function isContentType(response: Response, expected: string) { + return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected +} diff --git a/packages/client/src/generated/index.ts b/packages/client/src/generated/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2570372cf8148b8365d6c48b835fea5bdf2b3056 --- /dev/null +++ b/packages/client/src/generated/index.ts @@ -0,0 +1,3 @@ +export { ClientError, type ClientErrorReason } from "./client-error" +export * as OpenCode from "./client" +export * from "./types" diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b3188c8742a4ba6b5a173d296a4ac28bac710fc --- /dev/null +++ b/packages/client/src/generated/types.ts @@ -0,0 +1,2807 @@ +import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event" + +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue } + +export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string } +export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError" + +export type InvalidRequestError = { + readonly _tag: "InvalidRequestError" + readonly message: string + readonly kind?: string | undefined + readonly field?: string | undefined +} +export const isInvalidRequestError = (value: unknown): value is InvalidRequestError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError" + +export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string } +export const isInvalidCursorError = (value: unknown): value is InvalidCursorError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError" + +export type SessionNotFoundError = { + readonly _tag: "SessionNotFoundError" + readonly sessionID: string + readonly message: string +} +export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError" + +export type ConflictError = { + readonly _tag: "ConflictError" + readonly message: string + readonly resource?: string | undefined +} +export const isConflictError = (value: unknown): value is ConflictError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError" + +export type ServiceUnavailableError = { + readonly _tag: "ServiceUnavailableError" + readonly message: string + readonly service?: string | undefined +} +export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError" + +export type MessageNotFoundError = { + readonly _tag: "MessageNotFoundError" + readonly sessionID: string + readonly messageID: string + readonly message: string +} +export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError" + +export type UnknownError = { + readonly _tag: "UnknownError" + readonly message: string + readonly ref?: string | undefined +} +export const isUnknownError = (value: unknown): value is UnknownError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError" + +export type ProviderNotFoundError = { + readonly _tag: "ProviderNotFoundError" + readonly providerID: string + readonly message: string +} +export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError" + +export type PermissionNotFoundError = { + readonly _tag: "PermissionNotFoundError" + readonly requestID: string + readonly message: string +} +export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError" + +export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly ptyID: string; readonly message: string } +export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError" + +export type QuestionNotFoundError = { + readonly _tag: "QuestionNotFoundError" + readonly requestID: string + readonly message: string +} +export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError" + +export type ProjectCopyError = { + readonly name: "ProjectCopyError" + readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined } +} +export const isProjectCopyError = (value: unknown): value is ProjectCopyError => + typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError" + +export type HealthGetOutput = { readonly healthy: true } + +export type LocationGetInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type LocationGetOutput = { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } +} + +export type AgentsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type AgentsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly request: { + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + } + readonly system?: string + readonly description?: string + readonly mode: "subagent" | "primary" | "all" + readonly hidden: boolean + readonly color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + readonly steps?: number + readonly permissions: ReadonlyArray<{ + readonly action: string + readonly resource: string + readonly effect: "allow" | "deny" | "ask" + }> + }> +} + +export type SessionsListInput = { + readonly workspace?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["workspace"] + readonly limit?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["limit"] + readonly order?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["order"] + readonly search?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["search"] + readonly directory?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["directory"] + readonly project?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["project"] + readonly subpath?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["subpath"] + readonly cursor?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["cursor"] +} + +export type SessionsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + }> + readonly cursor: { readonly previous?: string | null; readonly next?: string | null } +} + +export type SessionsCreateInput = { + readonly id?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + }["id"] + readonly agent?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + }["agent"] + readonly model?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + }["model"] + readonly location?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + }["location"] +} + +export type SessionsCreateOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } +}["data"] + +export type SessionsActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"] + +export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsGetOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } +}["data"] + +export type SessionsSwitchAgentInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly agent: { readonly agent: string }["agent"] +} + +export type SessionsSwitchAgentOutput = void + +export type SessionsSwitchModelInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly model: { + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + }["model"] +} + +export type SessionsSwitchModelOutput = void + +export type SessionsPromptInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | null + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["id"] + readonly prompt: { + readonly id?: string | null + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["prompt"] + readonly delivery?: { + readonly id?: string | null + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["delivery"] + readonly resume?: { + readonly id?: string | null + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["resume"] +} + +export type SessionsPromptOutput = { + readonly data: { + readonly admittedSeq: number + readonly id: string + readonly sessionID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + readonly timeCreated: number + readonly promotedSeq?: number + } +}["data"] + +export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsCompactOutput = void + +export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsWaitOutput = void + +export type SessionsStageInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"] + readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] +} + +export type SessionsStageOutput = { + readonly data: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } +}["data"] + +export type SessionsClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsClearOutput = void + +export type SessionsCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsCommitOutput = void + +export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsContextOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly structured: { readonly [x: string]: JsonValue } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + } + readonly time: { + readonly created: number + readonly ran?: number + readonly completed?: number + readonly pruned?: number + } + } + > + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly error?: { readonly type: "unknown"; readonly message: string } + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + } + > +}["data"] + +export type SessionsHistoryInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"] + readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"] +} + +export type SessionsHistoryOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.agent.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.model.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.moved" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.prompted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.prompt.admitted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.context.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.synthetic" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.shell.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.shell.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string + readonly files?: ReadonlyArray + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.text.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.text.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.input.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.input.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.called" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: JsonValue } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.progress" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.success" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly result?: JsonValue + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.reasoning.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.reasoning.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.compaction.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.staged" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.cleared" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.committed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + > + readonly hasMore: boolean +} + +export type SessionsEventsInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly after?: { readonly after?: number | undefined }["after"] +} + +export type SessionsEventsOutput = + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.agent.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.model.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.moved" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.prompted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.prompt.admitted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.context.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.synthetic" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.shell.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.shell.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string + readonly files?: ReadonlyArray + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.called" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: unknown } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.progress" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: unknown } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.success" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: unknown } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly result?: unknown + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: unknown + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.staged" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.cleared" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.committed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + +export type SessionsInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsInterruptOutput = void + +export type SessionsMessageInput = { + readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"] + readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"] +} + +export type SessionsMessageOutput = { + readonly data: + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly structured: { readonly [x: string]: JsonValue } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + } + readonly time: { + readonly created: number + readonly ran?: number + readonly completed?: number + readonly pruned?: number + } + } + > + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly error?: { readonly type: "unknown"; readonly message: string } + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + } +}["data"] + +export type MessagesListInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly limit?: { + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly cursor?: string | undefined + }["limit"] + readonly order?: { + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly cursor?: string | undefined + }["order"] + readonly cursor?: { + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly cursor?: string | undefined + }["cursor"] +} + +export type MessagesListOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly structured: { readonly [x: string]: JsonValue } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + } + readonly time: { + readonly created: number + readonly ran?: number + readonly completed?: number + readonly pruned?: number + } + } + > + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly error?: { readonly type: "unknown"; readonly message: string } + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + } + > + readonly cursor: { readonly previous?: string | null; readonly next?: string | null } +} + +export type ModelsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ModelsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly providerID: string + readonly family?: string + readonly name: string + readonly api: + | { + readonly id: string + readonly type: "aisdk" + readonly package: string + readonly url?: string + readonly settings?: { readonly [x: string]: JsonValue } + } + | { + readonly id: string + readonly type: "native" + readonly url?: string + readonly settings: { readonly [x: string]: JsonValue } + } + readonly capabilities: { + readonly tools: boolean + readonly input: ReadonlyArray + readonly output: ReadonlyArray + } + readonly request: { + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + readonly variant?: string + } + readonly variants: ReadonlyArray<{ + readonly id: string + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + }> + readonly time: { readonly released: number } + readonly cost: ReadonlyArray<{ + readonly tier?: { readonly type: "context"; readonly size: number } + readonly input: number + readonly output: number + readonly cache: { readonly read: number; readonly write: number } + }> + readonly status: "alpha" | "beta" | "deprecated" | "active" + readonly enabled: boolean + readonly limit: { readonly context: number; readonly input?: number; readonly output: number } + }> +} + +export type ProvidersListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ProvidersListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly integrationID?: string + readonly name: string + readonly disabled?: boolean + readonly api: + | { + readonly type: "aisdk" + readonly package: string + readonly url?: string + readonly settings?: { readonly [x: string]: JsonValue } + } + | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } + readonly request: { + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + } + }> +} + +export type ProvidersGetInput = { + readonly providerID: { readonly providerID: string }["providerID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ProvidersGetOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly integrationID?: string + readonly name: string + readonly disabled?: boolean + readonly api: + | { + readonly type: "aisdk" + readonly package: string + readonly url?: string + readonly settings?: { readonly [x: string]: JsonValue } + } + | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } + readonly request: { + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + } + } +} + +export type IntegrationsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly name: string + readonly methods: ReadonlyArray< + | { + readonly id: string + readonly type: "oauth" + readonly label: string + readonly prompts?: ReadonlyArray< + | { + readonly type: "text" + readonly key: string + readonly message: string + readonly placeholder?: string + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + | { + readonly type: "select" + readonly key: string + readonly message: string + readonly options: ReadonlyArray<{ + readonly label: string + readonly value: string + readonly hint?: string + }> + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + > + } + | { readonly type: "key"; readonly label?: string } + | { readonly type: "env"; readonly names: ReadonlyArray } + > + readonly connections: ReadonlyArray< + | { readonly type: "credential"; readonly id: string; readonly label: string } + | { readonly type: "env"; readonly name: string } + > + }> +} + +export type IntegrationsGetInput = { + readonly integrationID: { readonly integrationID: string }["integrationID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationsGetOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly name: string + readonly methods: ReadonlyArray< + | { + readonly id: string + readonly type: "oauth" + readonly label: string + readonly prompts?: ReadonlyArray< + | { + readonly type: "text" + readonly key: string + readonly message: string + readonly placeholder?: string + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + | { + readonly type: "select" + readonly key: string + readonly message: string + readonly options: ReadonlyArray<{ + readonly label: string + readonly value: string + readonly hint?: string + }> + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + > + } + | { readonly type: "key"; readonly label?: string } + | { readonly type: "env"; readonly names: ReadonlyArray } + > + readonly connections: ReadonlyArray< + | { readonly type: "credential"; readonly id: string; readonly label: string } + | { readonly type: "env"; readonly name: string } + > + } | null +} + +export type IntegrationsConnectKeyInput = { + readonly integrationID: { readonly integrationID: string }["integrationID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly key: { readonly key: string; readonly label?: string | undefined }["key"] + readonly label?: { readonly key: string; readonly label?: string | undefined }["label"] +} + +export type IntegrationsConnectKeyOutput = void + +export type IntegrationsConnectOauthInput = { + readonly integrationID: { readonly integrationID: string }["integrationID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly methodID: { + readonly methodID: string + readonly inputs: { readonly [x: string]: string } + readonly label?: string | undefined + }["methodID"] + readonly inputs: { + readonly methodID: string + readonly inputs: { readonly [x: string]: string } + readonly label?: string | undefined + }["inputs"] + readonly label?: { + readonly methodID: string + readonly inputs: { readonly [x: string]: string } + readonly label?: string | undefined + }["label"] +} + +export type IntegrationsConnectOauthOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly attemptID: string + readonly url: string + readonly instructions: string + readonly mode: "auto" | "code" + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } +} + +export type IntegrationsAttemptStatusInput = { + readonly attemptID: { readonly attemptID: string }["attemptID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationsAttemptStatusOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: + | { + readonly status: "pending" + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } + | { + readonly status: "complete" + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } + | { + readonly status: "failed" + readonly message: string + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } + | { + readonly status: "expired" + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly expires: number | "Infinity" | "-Infinity" | "NaN" + } + } +} + +export type IntegrationsAttemptCompleteInput = { + readonly attemptID: { readonly attemptID: string }["attemptID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly code?: { readonly code?: string | undefined }["code"] +} + +export type IntegrationsAttemptCompleteOutput = void + +export type IntegrationsAttemptCancelInput = { + readonly attemptID: { readonly attemptID: string }["attemptID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationsAttemptCancelOutput = void + +export type CredentialsUpdateInput = { + readonly credentialID: { readonly credentialID: string }["credentialID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly label: { readonly label: string }["label"] +} + +export type CredentialsUpdateOutput = void + +export type CredentialsRemoveInput = { + readonly credentialID: { readonly credentialID: string }["credentialID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type CredentialsRemoveOutput = void + +export type PermissionsListRequestsInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PermissionsListRequestsOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + }> +} + +export type PermissionsListSavedInput = { + readonly projectID?: { readonly projectID?: string | undefined }["projectID"] +} + +export type PermissionsListSavedOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly projectID: string + readonly action: string + readonly resource: string + }> +}["data"] + +export type PermissionsRemoveSavedInput = { readonly id: { readonly id: string }["id"] } + +export type PermissionsRemoveSavedOutput = void + +export type PermissionsCreateInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["id"] + readonly action: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["action"] + readonly resources: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["resources"] + readonly save?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["save"] + readonly metadata?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["metadata"] + readonly source?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["source"] + readonly agent?: { + readonly id?: string | null + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + readonly agent?: string | null + }["agent"] +} + +export type PermissionsCreateOutput = { + readonly data: { readonly id: string; readonly effect: "allow" | "deny" | "ask" } +}["data"] + +export type PermissionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type PermissionsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + }> +}["data"] + +export type PermissionsGetInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] +} + +export type PermissionsGetOutput = { + readonly data: { + readonly id: string + readonly sessionID: string + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: JsonValue } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + } +}["data"] + +export type PermissionsReplyInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] + readonly reply: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["reply"] + readonly message?: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["message"] +} + +export type PermissionsReplyOutput = void + +export type FilesListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly path?: string | undefined + }["location"] + readonly path?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly path?: string | undefined + }["path"] +} + +export type FilesListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }> +} + +export type FilesFindInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly query: string + readonly type?: "file" | "directory" | undefined + readonly limit?: number | undefined + }["location"] + readonly query: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly query: string + readonly type?: "file" | "directory" | undefined + readonly limit?: number | undefined + }["query"] + readonly type?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly query: string + readonly type?: "file" | "directory" | undefined + readonly limit?: number | undefined + }["type"] + readonly limit?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly query: string + readonly type?: "file" | "directory" | undefined + readonly limit?: number | undefined + }["limit"] +} + +export type FilesFindOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }> +} + +export type CommandsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type CommandsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly name: string + readonly template: string + readonly description?: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly subtask?: boolean + }> +} + +export type SkillsListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type SkillsListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly name: string + readonly description?: string + readonly slash?: boolean + readonly location: string + readonly content: string + }> +} + +export type EventsSubscribeOutput = OpenCodeEventEncoded + +export type PtysListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PtysListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + }> +} + +export type PtysCreateInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly command?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["command"] + readonly args?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["args"] + readonly cwd?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["cwd"] + readonly title?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["title"] + readonly env?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + }["env"] +} + +export type PtysCreateOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + } +} + +export type PtysGetInput = { + readonly ptyID: { readonly ptyID: string }["ptyID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PtysGetOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + } +} + +export type PtysUpdateInput = { + readonly ptyID: { readonly ptyID: string }["ptyID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly title?: { + readonly title?: string + readonly size?: { readonly rows: number; readonly cols: number } + }["title"] + readonly size?: { readonly title?: string; readonly size?: { readonly rows: number; readonly cols: number } }["size"] +} + +export type PtysUpdateOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + } +} + +export type PtysRemoveInput = { + readonly ptyID: { readonly ptyID: string }["ptyID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PtysRemoveOutput = void + +export type QuestionsListRequestsInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type QuestionsListRequestsOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly questions: ReadonlyArray<{ + readonly question: string + readonly header: string + readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }> + readonly multiple?: boolean + readonly custom?: boolean + }> + readonly tool?: { readonly messageID: string; readonly callID: string } + }> +} + +export type QuestionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type QuestionsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly questions: ReadonlyArray<{ + readonly question: string + readonly header: string + readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }> + readonly multiple?: boolean + readonly custom?: boolean + }> + readonly tool?: { readonly messageID: string; readonly callID: string } + }> +}["data"] + +export type QuestionsReplyInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] + readonly answers: { readonly answers: ReadonlyArray> }["answers"] +} + +export type QuestionsReplyOutput = void + +export type QuestionsRejectInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] +} + +export type QuestionsRejectOutput = void + +export type ReferencesListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ReferencesListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly name: string + readonly path: string + readonly description?: string + readonly hidden?: boolean + readonly source: + | { readonly type: "local"; readonly path: string; readonly description?: string; readonly hidden?: boolean } + | { + readonly type: "git" + readonly repository: string + readonly branch?: string + readonly description?: string + readonly hidden?: boolean + } + }> +} + +export type ProjectCopiesCreateInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"] + readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"] + readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"] +} + +export type ProjectCopiesCreateOutput = { readonly directory: string } + +export type ProjectCopiesRemoveInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly directory: { readonly directory: string; readonly force: boolean }["directory"] + readonly force: { readonly directory: string; readonly force: boolean }["force"] +} + +export type ProjectCopiesRemoveOutput = void + +export type ProjectCopiesRefreshInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ProjectCopiesRefreshOutput = void diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6955d7d8c587912ecf982b27cbb4850380b5d622 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,2 @@ +export * from "./generated/index" +export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types" diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..64a2e958ce2d86e6f66e03d5b9353dbd1ffb358a --- /dev/null +++ b/packages/client/test/contract-identity.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Location as CoreLocation } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input" +import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message" +import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt" +import { Agent } from "@opencode-ai/schema/agent" +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 { Prompt } from "@opencode-ai/schema/prompt" +import { Session } from "@opencode-ai/schema/session" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Api } from "@opencode-ai/server/api" +import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" +import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract" + +test("Core and Server reuse the authoritative Schema and Protocol values", () => { + expect(AgentV2.ID).toBe(Agent.ID) + expect(CoreLocation.Ref).toBe(Location.Ref) + expect(ModelV2.Ref).toBe(Model.Ref) + expect(SessionV2.Info).toBe(Session.Info) + expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) + expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) + expect(CorePrompt).toBe(Prompt) + expect(Api.groups["server.session"].identifier).toBe("server.session") + expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups)) + expect(Session.ID.create()).toStartWith("ses_") + expect(Project.ID.global).toBe("global") + expect(Provider.ID.anthropic).toBe("anthropic") + expect(Workspace.ID.create()).toStartWith("wrk_") +}) + +test("client and Server contracts generate identically", () => { + const server = compile(Api, { groupNames, endpointNames, omitEndpoints }) + const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints }) + + expect(emitPromise(client)).toEqual(emitPromise(server)) +}) + +test("shared DTO schemas construct and decode plain objects", () => { + const made = Prompt.make({ text: "hello" }) + const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) + const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" }) + + expect(Object.getPrototypeOf(made)).toBe(Object.prototype) + expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype) + expect(Object.getPrototypeOf(content)).toBe(Object.prototype) + expect(Prompt.ast.annotations?.identifier).toBe("Prompt") + expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text") + expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText) +}) diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bf4d26f8fecb2aa054e907e43a4fc6b9a0fff68 --- /dev/null +++ b/packages/client/test/effect.test.ts @@ -0,0 +1,246 @@ +import { expect, test } from "bun:test" +import { DateTime, Effect, Stream } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" + +test("sessions.get returns the decoded Effect projection", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))), + ) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") }) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) +}) + +test("events.subscribe exposes and decodes the native Effect event stream", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ) + const events = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.events.subscribe().pipe(Stream.runCollect) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"]) + const durable = events[1] + if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event") + expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000) + expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) +}) + +test("events.subscribe terminates on Effect protocol decode failures", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`data: {"type":"server.connected"}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + ), + ), + ) + const error = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("ClientError") +}) + +test("session methods retain decoded Effect inputs and outputs", async () => { + const historyQueries: Array> = [] + let historyPage = 0 + const httpClient = HttpClient.make((request) => { + const url = request.url + if (url.includes("/event")) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + ), + ) + } + if (url.includes("/history")) { + historyPage++ + historyQueries.push(Object.fromEntries(request.urlParams.params)) + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false }, + ), + ), + ) + } + if (url.includes("/prompt")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) + } + if (url.includes("/context")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) + } + if (url.includes("/message/")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage }))) + } + if (url.endsWith("/api/session/active")) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })), + ) + } + if (request.method === "POST" && url.endsWith("/api/session")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) + } + if (request.method === "POST") { + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + } + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })), + ) + }) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + const page = yield* client.sessions.list({ limit: 10 }) + const active = yield* client.sessions.active() + const created = yield* client.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), + }) + yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) + yield* client.sessions.switchModel({ + sessionID: Session.ID.make("ses_test"), + model: Model.Ref.make({ id: "claude", providerID: "anthropic" }), + }) + const admitted = yield* client.sessions.prompt({ + sessionID: Session.ID.make("ses_test"), + prompt: Prompt.make({ text: "Hello" }), + resume: false, + }) + yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) + yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) + const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) + const history = yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + after: 0, + limit: 1, + }) + const historyNext = history.hasMore + ? yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + after: history.data.at(-1)?.durable?.seq, + limit: 2, + }) + : undefined + const events = yield* client.sessions + .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) + .pipe(Stream.runCollect) + yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") }) + const message = yield* client.sessions.message({ + sessionID: Session.ID.make("ses_test"), + messageID: SessionMessage.ID.make("msg_model"), + }) + return { page, active, created, admitted, context, history, historyNext, events, message } + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) + expect(result.active).toEqual({ ses_test: { type: "running" } }) + expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) + expect(result.created.id).toBe("ses_test") + expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) + expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) + expect(result.context).toEqual([]) + expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.history).toEqual(expect.objectContaining({ hasMore: true })) + expect(result.historyNext).toEqual({ data: [], hasMore: false }) + expect(historyQueries[0]).toEqual({ limit: "1", after: "0" }) + expect(historyQueries[1]).toEqual({ limit: "2", after: "1" }) + expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) +}) + +test("sessions.history retains the typed SessionNotFoundError", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), + ), + ), + ) + const error = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.sessions + .history({ + sessionID: Session.ID.make("ses_missing"), + }) + .pipe(Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("SessionNotFoundError") +}) + +const session = { + data: { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { read: 4, write: 5 }, + }, + time: { + created: 1_717_171_717_000, + updated: 1_717_171_717_000, + }, + title: "Test", + location: { directory: "/tmp/project" }, + }, +} + +const admission = { + data: { + admittedSeq: 0, + id: "msg_test", + sessionID: "ses_test", + prompt: { text: "Hello" }, + delivery: "steer", + timeCreated: 1_717_171_717_000, + }, +} + +const modelSwitchedMessage = { + id: "msg_model", + type: "model-switched", + time: { created: 1_717_171_717_000 }, + model: { id: "claude", providerID: "anthropic" }, +} + +const modelSwitchedEvent = { + id: "evt_model", + type: "session.next.model.switched", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { + timestamp: 1_717_171_717_000, + sessionID: "ses_test", + messageID: "msg_model", + model: { id: "claude", providerID: "anthropic" }, + }, +} diff --git a/packages/client/test/import-boundaries.test.ts b/packages/client/test/import-boundaries.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4875a3a5dc0d659e0afae2e480cafac3775750c9 --- /dev/null +++ b/packages/client/test/import-boundaries.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { realpathSync } from "node:fs" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect")) +const schema = resolve(import.meta.dir, "../../schema") +const protocol = resolve(import.meta.dir, "../../protocol") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +describe("public import boundaries", () => { + test("isolates each public entrypoint", async () => { + const root = await bundleInputs("@opencode-ai/client", "browser") + + expect(within(root, effect)).toEqual([]) + expect(within(root, schema)).toEqual([]) + expect(within(root, protocol)).toEqual([]) + expect(within(root, core)).toEqual([]) + expect(within(root, server)).toEqual([]) + + const network = await bundleInputs("@opencode-ai/client/effect", "browser") + + expect(within(network, effect).length).toBeGreaterThan(0) + expect(within(network, schema).length).toBeGreaterThan(0) + expect(within(network, protocol).length).toBeGreaterThan(0) + expect(within(network, core)).toEqual([]) + expect(within(network, server)).toEqual([]) + }) +}) + +async function bundleInputs(specifier: string, target: "browser" | "bun") { + const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) + const entrypoint = join(temporary, "index.ts") + const metafile = join(temporary, "meta.json") + try { + await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`) + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + `--target=${target}`, + "--format=esm", + "--packages=bundle", + `--metafile=${metafile}`, + `--outdir=${join(temporary, "out")}`, + ], + { cwd: directory, stdout: "pipe", stderr: "pipe" }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (exitCode !== 0) throw new Error(stdout + stderr) + const metadata = await Bun.file(metafile).json() + return Object.keys(metadata.inputs).map((input) => resolve(directory, input)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +function within(inputs: ReadonlyArray, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..322a39cd6b296dffa0fc0bd0789b8de645ae8376 --- /dev/null +++ b/packages/client/test/promise.test.ts @@ -0,0 +1,255 @@ +import { expect, test } from "bun:test" +import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src" + +test("exposes every standard HTTP API group", () => { + const client = OpenCode.make({ baseUrl: "http://localhost:3000" }) + + expect(Object.keys(client)).toEqual([ + "health", + "location", + "agents", + "sessions", + "messages", + "models", + "providers", + "integrations", + "credentials", + "permissions", + "files", + "commands", + "skills", + "events", + "ptys", + "questions", + "references", + "projectCopies", + ]) + expect(Object.keys(client.messages)).toEqual(["list"]) + expect(Object.keys(client.integrations)).toEqual([ + "list", + "get", + "connectKey", + "connectOauth", + "attemptStatus", + "attemptComplete", + "attemptCancel", + ]) + expect(Object.keys(client.files)).toEqual(["list", "find"]) + expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"]) +}) + +test("sessions.get returns the wire projection", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe( + "http://localhost:3000/api/session/ses_test", + ) + return Response.json(session) + }, + }) + + const result = await client.sessions.get({ sessionID: "ses_test" }) + + expect(result.time.created).toBe(1_717_171_717_000) +}) + +test("events.subscribe exposes the Promise event stream wire projection", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response( + `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + }) + const events = [] + for await (const event of client.events.subscribe()) events.push(event) + + expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent]) + expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) +}) + +test("events.subscribe terminates on malformed Promise SSE data", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }), + }) + + await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + name: "ClientError", + reason: "MalformedResponse", + }) +}) + +test("session methods use the public HTTP contract", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = [] + let historyPage = 0 + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push({ url, init }) + if (url.includes("/event")) { + return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + } + if (url.includes("/history")) { + historyPage++ + return Response.json( + historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false }, + ) + } + if (url.includes("/prompt")) return Response.json(admission) + if (url.includes("/context")) return Response.json({ data: [] }) + if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) + if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } }) + if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) + if (init?.method === "POST") return new Response(null, { status: 204 }) + return Response.json({ data: [session.data], cursor: { next: "next" } }) + }, + }) + + const page = await client.sessions.list({ limit: 10, order: "desc" }) + const active = await client.sessions.active() + const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) + await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) + await client.sessions.switchModel({ + sessionID: "ses_test", + model: { id: "claude", providerID: "anthropic" }, + }) + const admitted = await client.sessions.prompt({ + sessionID: "ses_test", + prompt: { text: "Hello" }, + resume: false, + }) + await client.sessions.compact({ sessionID: "ses_test" }) + await client.sessions.wait({ sessionID: "ses_test" }) + const context = await client.sessions.context({ sessionID: "ses_test" }) + const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 }) + const historyAfter = history.data.at(-1)?.durable?.seq + const historyNext = history.hasMore + ? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 }) + : undefined + const events = [] + for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event) + await client.sessions.interrupt({ sessionID: "ses_test" }) + const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) + + expect(page.cursor.next).toBe("next") + expect(active).toEqual({ ses_test: { type: "running" } }) + expect(created.id).toBe("ses_test") + expect(admitted.id).toBe("msg_test") + expect(context).toEqual([]) + expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true }) + expect(historyNext).toEqual({ data: [], hasMore: false }) + expect(events).toEqual([modelSwitchedEvent]) + expect(message).toEqual(modelSwitchedMessage) + expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ + ["GET", "http://localhost:3000/api/session?limit=10&order=desc"], + ["GET", "http://localhost:3000/api/session/active"], + ["POST", "http://localhost:3000/api/session"], + ["POST", "http://localhost:3000/api/session/ses_test/agent"], + ["POST", "http://localhost:3000/api/session/ses_test/model"], + ["POST", "http://localhost:3000/api/session/ses_test/prompt"], + ["POST", "http://localhost:3000/api/session/ses_test/compact"], + ["POST", "http://localhost:3000/api/session/ses_test/wait"], + ["GET", "http://localhost:3000/api/session/ses_test/context"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"], + ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], + ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], + ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], + ]) + const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body + if (typeof body !== "string") throw new Error("Expected JSON request body") + expect(JSON.parse(body)).toEqual({ + prompt: { text: "Hello" }, + resume: false, + }) +}) + +test("middleware errors remain declared client errors", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }), + }) + + try { + await client.sessions.create({}) + throw new Error("Expected request to fail") + } catch (error) { + expect(isUnauthorizedError(error)).toBe(true) + } +}) + +test("sessions.history decodes SessionNotFoundError", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), + }) + + try { + await client.sessions.history({ sessionID: "ses_missing" }) + throw new Error("Expected request to fail") + } catch (error) { + expect(isSessionNotFoundError(error)).toBe(true) + } +}) + +const session = { + data: { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { read: 4, write: 5 }, + }, + time: { + created: 1_717_171_717_000, + updated: 1_717_171_717_000, + }, + title: "Test", + location: { directory: "/tmp/project" }, + }, +} + +const admission = { + data: { + admittedSeq: 0, + id: "msg_test", + sessionID: "ses_test", + prompt: { text: "Hello" }, + delivery: "steer", + timeCreated: 1_717_171_717_000, + }, +} + +const modelSwitchedMessage = { + id: "msg_model", + type: "model-switched", + time: { created: 1_717_171_717_000 }, + model: { id: "claude", providerID: "anthropic" }, +} + +const modelSwitchedEvent = { + id: "evt_model", + type: "session.next.model.switched", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { + timestamp: 1_717_171_717_000, + sessionID: "ses_test", + messageID: "msg_model", + model: { id: "claude", providerID: "anthropic" }, + }, +} diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts new file mode 100644 index 0000000000000000000000000000000000000000..14782d7c8aaed7b4647e64f6631459bdd4684a64 --- /dev/null +++ b/packages/codemode/src/codemode.ts @@ -0,0 +1,159 @@ +import { Effect, Schema } from "effect" +import { executeWithLimits } from "./interpreter/runtime.js" +import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" +import type { Definition } from "./tool.js" + +/** A tool call admitted during an execution. */ +export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js" + +/** Resource budgets enforced independently during each CodeMode program execution. */ +export type ExecutionLimits = { + /** Maximum wall-clock execution time in milliseconds. No default: absent means no timeout. */ + readonly timeoutMs?: number + /** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */ + readonly maxToolCalls?: number + /** Maximum UTF-8 bytes of model-facing output. No default: absent means no truncation. */ + readonly maxOutputBytes?: number +} + +/** Controls how much of the tool catalog is inlined in agent instructions. */ +export type DiscoveryOptions = { + /** Approximate token budget (chars/4, default 2000) for full catalog entries. */ + readonly catalogBudget?: number +} + +type ToolTree = { + readonly [name: string]: Definition | ToolTree +} + +export type ResolvedExecutionLimits = { + readonly timeoutMs: number | undefined + readonly maxToolCalls: number | undefined + readonly maxOutputBytes: number | undefined +} + +/** Options for one CodeMode execution. */ +export type ExecuteOptions = {}> = { + /** Source for one program in the supported JavaScript subset. */ + code: string + /** Explicit tool tree exposed to the program as `tools`. */ + tools?: Tools & ToolTree> + /** Per-execution overrides for the default resource limits. */ + limits?: ExecutionLimits + /** Observes decoded tool input immediately before tool execution. */ + onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect> + /** Observes each admitted tool call as it settles, with outcome and duration. */ + onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> +} + +/** A JSON value that can cross the confined interpreter boundary. */ +export type DataValue = Schema.Json + +/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */ +export type Options = {}> = Omit, "code"> & { + /** Progressive-disclosure configuration for the agent-facing tool catalog. */ + readonly discovery?: DiscoveryOptions +} + +/** Schema for a host tool input containing CodeMode source. */ +export const Input = Schema.Struct({ code: Schema.String }) +export type Input = typeof Input.Type + +export const DiagnosticKind = Schema.Literals([ + "ParseError", + "UnsupportedSyntax", + "UnknownTool", + "InvalidToolInput", + "InvalidToolOutput", + "InvalidDataValue", + "ToolCallLimitExceeded", + "TimeoutExceeded", + "ToolFailure", + "ExecutionFailure", +]) +/** Stable categories produced by program, schema, tool, and limit failures. */ +export type DiagnosticKind = typeof DiagnosticKind.Type + +export const Diagnostic = Schema.Struct({ + kind: DiagnosticKind, + message: Schema.String, + location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })), + suggestions: Schema.optionalKey(Schema.Array(Schema.String)), +}) +/** A normalized program diagnostic safe to return across an agent tool boundary. */ +export type Diagnostic = typeof Diagnostic.Type + +const ToolCallSchema = Schema.Struct({ name: Schema.String }) +export const Success = Schema.Struct({ + ok: Schema.Literal(true), + value: Schema.Json, + logs: Schema.optionalKey(Schema.Array(Schema.String)), + truncated: Schema.optionalKey(Schema.Boolean), + toolCalls: Schema.Array(ToolCallSchema), +}) +/** Successful execution after the result has crossed the plain-data boundary. */ +export type Success = typeof Success.Type + +export const Failure = Schema.Struct({ + ok: Schema.Literal(false), + error: Diagnostic, + logs: Schema.optionalKey(Schema.Array(Schema.String)), + truncated: Schema.optionalKey(Schema.Boolean), + toolCalls: Schema.Array(ToolCallSchema), +}) +/** Failed execution with calls admitted before the diagnostic was produced. */ +export type Failure = typeof Failure.Type + +/** Schema for the structured success or diagnostic returned by CodeMode execution. */ +export const Result = Schema.Union([Success, Failure]) +/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */ +export type Result = typeof Result.Type + +/** Reusable confined runtime over one explicit tool tree. */ +export type Runtime = { + readonly catalog: () => ReadonlyArray + readonly instructions: () => string + readonly execute: (code: string) => Effect.Effect +} + +const validateLimit = ( + name: keyof ExecutionLimits, + value: Value, + minimum: number, +): Value => { + if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { + throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) + } + return value +} + +const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ + timeoutMs: validateLimit("timeoutMs", limits?.timeoutMs, 1), + maxToolCalls: validateLimit("maxToolCalls", limits?.maxToolCalls, 0), + maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0), +}) + +/** Executes one Effect-native CodeMode program without constructing a reusable runtime. */ +export const execute = >( + options: ExecuteOptions, +): Effect.Effect> => { + const tools = (options.tools ?? {}) as HostTools> + ToolRuntime.assertValidTools(tools) + return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) +} + +/** Creates an Effect-native runtime over explicit, schema-described tools. */ +export const make = = {}>( + options: Options = {} as Options, +): Runtime> => { + const tools = (options.tools ?? {}) as HostTools> + ToolRuntime.assertValidTools(tools) + const limits = resolveExecutionLimits(options.limits) + const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget) + + return { + catalog: () => prepared.catalog, + instructions: () => prepared.instructions, + execute: (code) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex), + } +} diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..92019825004b5455fe4ccdd8f168cb2051d70a57 --- /dev/null +++ b/packages/codemode/src/index.ts @@ -0,0 +1,4 @@ +export * as CodeMode from "./codemode.js" +export * as Tool from "./tool.js" +export * as OpenAPI from "./openapi/index.js" +export { ToolError, toolError } from "./tool-error.js" diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..d95351451035ead43c7ad9876bd49654aa163626 --- /dev/null +++ b/packages/codemode/src/interpreter/model.ts @@ -0,0 +1,201 @@ +import type { SafeObject } from "../tool-runtime.js" +import type { SandboxURL } from "../values.js" + +export type SourcePosition = { + line: number + column: number +} + +export type SourceLocation = { + start: SourcePosition + end: SourcePosition +} + +export type AstNode = { + type: string + loc?: SourceLocation + [key: string]: unknown +} + +export type ProgramNode = AstNode & { + type: "Program" + body: Array +} + +export type Binding = { + mutable: boolean + value: unknown + initialized?: boolean +} + +export type StatementResult = + | { kind: "none" } + | { kind: "value"; value: unknown } + | { kind: "return"; value: unknown } + | { kind: "break" } + | { kind: "continue" } + +export type MemberReference = { + target: SafeObject | Array | SandboxURL + key: string | number +} + +export class CodeModeFunction { + constructor( + readonly parameters: ReadonlyArray, + readonly body: AstNode, + readonly capturedScopes: ReadonlyArray>, + ) {} +} + +export class IntrinsicReference { + constructor( + readonly receiver: unknown, + readonly name: string, + ) {} +} + +export class ComputedValue { + constructor(readonly value: unknown) {} +} + +export class PromiseNamespace {} + +export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject" + +export class PromiseMethodReference { + constructor(readonly name: PromiseMethodName) {} +} + +export type GlobalNamespaceName = + | "Object" + | "Math" + | "JSON" + | "Array" + | "console" + | "Date" + | "RegExp" + | "Map" + | "Set" + | "URL" + | "URLSearchParams" + +export class GlobalNamespace { + constructor(readonly name: GlobalNamespaceName) {} +} + +export class GlobalMethodReference { + constructor( + readonly namespace: GlobalNamespaceName | "Number" | "String", + readonly name: string, + ) {} +} + +export class CoercionFunction { + constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} +} + +export class UriFunction { + constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {} +} + +export class ProgramThrow { + constructor(readonly value: unknown) {} +} + +export class ErrorConstructorReference { + constructor(readonly name: string) {} +} + +export type DiagnosticKind = + | "ParseError" + | "UnsupportedSyntax" + | "UnknownTool" + | "InvalidToolInput" + | "InvalidToolOutput" + | "InvalidDataValue" + | "ToolCallLimitExceeded" + | "TimeoutExceeded" + | "ToolFailure" + | "ExecutionFailure" + +export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") + +export const supportedSyntaxMessage = + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)." + +export class InterpreterRuntimeError extends Error { + readonly node?: AstNode + errorName: string = "Error" + + constructor( + message: string, + node?: AstNode, + readonly kind: DiagnosticKind = "ExecutionFailure", + readonly suggestions?: ReadonlyArray, + ) { + super(message) + this.name = "InterpreterRuntimeError" + if (node) this.node = node + } + + as(errorName: string): this { + this.errorName = errorName + return this + } +} + +export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => + new InterpreterRuntimeError( + `Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +export const asNode = (value: unknown, context: string): AstNode => { + if (!isRecord(value) || typeof value.type !== "string") { + throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`) + } + return value as AstNode +} + +export const getArray = (node: AstNode, key: string): Array => { + const value = node[key] + if (!Array.isArray(value)) throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node) + return value +} + +export const getString = (node: AstNode, key: string): string => { + const value = node[key] + if (typeof value !== "string") throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node) + return value +} + +export const getBoolean = (node: AstNode, key: string): boolean => { + const value = node[key] + if (typeof value !== "boolean") throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node) + return value +} + +export const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => { + const value = node[key] + if (value === undefined || value === null) return undefined + return asNode(value, key) +} + +export const getNode = (node: AstNode, key: string): AstNode => asNode(node[key], key) + +export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({ + line: Math.max(1, (node.loc?.start.line ?? 2) - 1), + column: Math.max(1, (node.loc?.start.column ?? 4) - 3), +}) + +export const formatLocation = (node?: AstNode): string => { + if (!node?.loc) return "" + const location = sourceLocation(node) + return ` (line ${location.line}, col ${location.column})` +} diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..093f577765100e95597919986f606010fd82066e --- /dev/null +++ b/packages/codemode/src/interpreter/runtime.ts @@ -0,0 +1,3465 @@ +import { parse } from "acorn" +import { Cause, Effect, Exit, Fiber, Semaphore } from "effect" +import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" +import { + copyIn, + copyOut, + isBlockedMember, + ToolReference, + ToolRuntime, + ToolRuntimeError, + type HostTools, + type SafeObject, + type Services, +} from "../tool-runtime.js" +import { ToolError } from "../tool-error.js" +import type { + DataValue, + Diagnostic, + DiagnosticKind, + ExecuteOptions, + ResolvedExecutionLimits, + Result, +} from "../codemode.js" +import { + type AstNode, + asNode, + type Binding, + CodeModeFunction, + CoercionFunction, + ComputedValue, + ErrorConstructorReference, + GlobalMethodReference, + GlobalNamespace, + type GlobalNamespaceName, + formatLocation, + getArray, + getBoolean, + getNode, + getOptionalNode, + getString, + IntrinsicReference, + InterpreterRuntimeError, + isRecord, + type MemberReference, + OptionalShortCircuit, + PromiseMethodReference, + type PromiseMethodName, + PromiseNamespace, + ProgramThrow, + type ProgramNode, + type StatementResult, + sourceLocation, + supportedSyntaxMessage, + unsupportedSyntax, + UriFunction, +} from "./model.js" +import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js" +import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js" +import { dateMethods, dateStatics, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" +import { invokeJsonMethod } from "../stdlib/json.js" +import { invokeMathMethod, mathConstants } from "../stdlib/math.js" +import { + invokeNumberMethod, + invokeNumberStatic, + numberConstants, + numberMethods, + numberStatics, +} from "../stdlib/number.js" +import { invokeObjectMethod } from "../stdlib/object.js" +import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js" +import { + escapeRegexHint, + invokeRegExpMethod, + matchToValue, + regexpMethods, + regexpProperties, + regexFailureReason, + toHostRegex, +} from "../stdlib/regexp.js" +import { invokeStringStatic, stringMethods, stringStatics } from "../stdlib/string.js" +import { + urlMethods, + urlProperties, + urlSearchParamsMethods, + urlStatics, + urlWritableProperties, + invokeUriFunction, + invokeURLMethod, + invokeURLStatic, + uriArgument, + urlArgument, +} from "../stdlib/url.js" +import { + boundedData, + coerceToNumber, + coerceToString, + compoundOperators, + createErrorValue, + errorBrandName, + errorConstructors, + invokeCoercion, + valueConstructors, +} from "../stdlib/value.js" +import { + isSandboxValue, + SandboxDate, + SandboxMap, + SandboxPromise, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../values.js" + +const parseProgram = (code: string): ProgramNode => { + const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { + reportDiagnostics: true, + compilerOptions: { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + }, + }) + const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) + + if (diagnostic) { + throw new InterpreterRuntimeError( + `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, + undefined, + "ParseError", + ) + } + + const bodyStart = transpiled.outputText.indexOf("{") + 1 + const bodyEnd = transpiled.outputText.lastIndexOf("}") + const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) + const parsed = parse(executableCode, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + locations: true, + }) as unknown + + if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { + throw new InterpreterRuntimeError("Failed to parse script as a Program node.") + } + + return parsed as ProgramNode +} + +const publicErrorMessage = (message: string): string => + message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") + +const normalizeError = (error: unknown): Diagnostic => { + if (error instanceof InterpreterRuntimeError) { + return { + kind: error.kind, + message: `${error.message}${formatLocation(error.node)}`, + ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), + ...(error.suggestions ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolRuntimeError) { + return { + kind: error.kind, + message: error.message, + ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolError) { + return { kind: "ToolFailure", message: publicErrorMessage(error.message) } + } + + if (error instanceof ProgramThrow) { + const value = error.value + let message: string + if (containsRuntimeReference(value)) { + // A thrown tool/function reference must not leak its internal structure. + message = "a non-data value" + } else if (typeof value === "string") { + message = value + } else if ( + value !== null && + typeof value === "object" && + typeof (value as { message?: unknown }).message === "string" + ) { + message = (value as { message: string }).message + } else { + try { + message = JSON.stringify(copyOut(value)) ?? String(value) + } catch { + message = String(value) + } + } + return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } + } + + if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { + return { + kind: "ExecutionFailure", + message: "Execution exceeded the maximum nesting depth.", + } + } + + if (error instanceof Error) { + return { + kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", + message: publicErrorMessage(error.message), + } + } + + // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through + // path redaction so filesystem paths can never leak through the catch-all branch. + return { + kind: "ExecutionFailure", + message: publicErrorMessage(String(error)), + } +} + +// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers. +const caughtErrorValue = (thrown: unknown): unknown => { + if (thrown instanceof ProgramThrow) return thrown.value + if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) + const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" + return createErrorValue(name, normalizeError(thrown).message) +} + +const isRuntimeReference = (value: unknown): boolean => + value instanceof CodeModeFunction || + value instanceof ToolReference || + value instanceof IntrinsicReference || + value instanceof GlobalNamespace || + value instanceof GlobalMethodReference || + value instanceof PromiseNamespace || + value instanceof PromiseMethodReference || + value instanceof SandboxPromise || + value instanceof CoercionFunction || + value instanceof UriFunction || + value instanceof ErrorConstructorReference || + isSandboxValue(value) + +const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsRuntimeReference(item, seen)) + : Object.values(value).some((item) => containsRuntimeReference(item, seen)) + seen.delete(value) + return contains +} + +// Like containsRuntimeReference, but sandbox standard-library values count as data: +// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive +// coercion) rather than rejecting them as opaque interpreter machinery. +const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { + if (isSandboxValue(value)) return false + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsOpaqueReference(item, seen)) + : Object.values(value).some((item) => containsOpaqueReference(item, seen)) + seen.delete(value) + return contains +} + +// `typeof` never throws in JS; map every interpreter value to its JS-visible category. +// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly +// like a real JS promise. +const typeofValue = (value: unknown): string => { + if ( + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || + value instanceof PromiseMethodReference || + value instanceof PromiseNamespace || + value instanceof ErrorConstructorReference + ) + return "function" + if (value instanceof UriFunction) return "function" + if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" + if (value instanceof GlobalNamespace) { + return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" + } + return typeof value +} + +// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any +// left-hand value (opaque references included) without coercing it. Error checks use the +// error brand: `instanceof Error` accepts every branded error; a specific error type matches +// its own brand only (as in JS, where TypeError instances are also Error instances). +const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { + if (rhs instanceof ErrorConstructorReference) { + const brand = errorBrandName(lhs) + return brand !== undefined && (rhs.name === "Error" || brand === rhs.name) + } + if (rhs instanceof GlobalNamespace) { + switch (rhs.name) { + case "Date": + return lhs instanceof SandboxDate + case "RegExp": + return lhs instanceof SandboxRegExp + case "Map": + return lhs instanceof SandboxMap + case "Set": + return lhs instanceof SandboxSet + case "URL": + return lhs instanceof SandboxURL + case "URLSearchParams": + return lhs instanceof SandboxURLSearchParams + case "Array": + return Array.isArray(lhs) + case "Object": + return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function") + } + } + if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise + // Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so + // `x instanceof Number` is always false - exactly what it is for primitives in JS. + if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) { + return false + } + throw new InterpreterRuntimeError( + "The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.", + node, + ) +} + +const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { + const str = (index: number): string => { + const arg = args[index] + if (typeof arg !== "string") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) + return arg + } + const num = (index: number): number => { + const arg = args[index] + if (typeof arg !== "number") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) + return arg + } + const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) + const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) + + let result: unknown + switch (name) { + case "toLowerCase": + result = value.toLowerCase() + break + case "toUpperCase": + result = value.toUpperCase() + break + case "trim": + result = value.trim() + break + // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them. + case "trimStart": + case "trimLeft": + result = value.trimStart() + break + case "trimEnd": + case "trimRight": + result = value.trimEnd() + break + // Locale/options arguments are ignored: comparison runs with the host default locale, and + // the common use is a sort comparator where any consistent order works. + case "localeCompare": + result = value.localeCompare(str(0)) + break + case "normalize": { + const form = optStr(0) + try { + result = value.normalize(form) + } catch { + throw new InterpreterRuntimeError( + `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, + node, + ).as("RangeError") + } + break + } + case "split": { + if (args.length === 0) { + result = [value] + break + } + if (args[0] instanceof SandboxRegExp) { + result = value.split((args[0] as SandboxRegExp).regex, optNum(1)) + break + } + const requestedLimit = optNum(1) + result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) + break + } + case "slice": + result = value.slice(optNum(0), optNum(1)) + break + case "includes": + result = value.includes(str(0), optNum(1)) + break + case "startsWith": + result = value.startsWith(str(0), optNum(1)) + break + case "endsWith": + result = value.endsWith(str(0), optNum(1)) + break + case "indexOf": + result = value.indexOf(str(0), optNum(1)) + break + case "lastIndexOf": + result = value.lastIndexOf(str(0), optNum(1)) + break + case "replace": + case "replaceAll": { + if (args[0] instanceof SandboxRegExp) { + const pattern = (args[0] as SandboxRegExp).regex + const replacement = str(1) + if (name === "replaceAll" && !pattern.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) + break + } + if (name === "replace") { + result = value.replace(str(0), str(1)) + break + } + result = value.replaceAll(str(0), str(1)) + break + } + case "match": { + const pattern = toHostRegex(args[0], name, node) + const matched = value.match(pattern) + if (matched === null) return null + // A global match is a plain array of matched strings; a non-global match carries + // index/groups own properties, so bypass the copying data checkpoint to keep them. + if (pattern.global) return boundedData(matched, "String.match result") + return matchToValue(matched) + } + case "matchAll": { + const pattern = toHostRegex(args[0], name, node, "g") + if (!pattern.global) { + throw new InterpreterRuntimeError( + `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, + node, + ) + } + // Materialized as an array (not an iterator); each entry is a match array with + // index/groups own properties. Match count is bounded by the subject length. + return Array.from(value.matchAll(pattern), matchToValue) + } + case "search": { + result = value.search(toHostRegex(args[0], name, node)) + break + } + case "repeat": { + const count = num(0) + if (!Number.isFinite(count) || count < 0) + throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) + result = value.repeat(count) + break + } + case "padStart": + result = value.padStart(num(0), optStr(1)) + break + case "padEnd": + result = value.padEnd(num(0), optStr(1)) + break + case "charAt": + result = value.charAt(optNum(0) ?? 0) + break + case "at": + result = value.at(optNum(0) ?? 0) + break + case "substring": + result = value.substring(optNum(0) ?? 0, optNum(1)) + break + case "substr": + result = value.substr(optNum(0) ?? 0, optNum(1)) + break + // JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value + // (normalized to null only at the data boundary - see copyOut), so return it as-is. + case "charCodeAt": + result = value.charCodeAt(optNum(0) ?? 0) + break + case "codePointAt": + result = value.codePointAt(optNum(0) ?? 0) + break + case "toString": + result = value + break + case "concat": { + result = value.concat(...args.map((_, index) => str(index))) + break + } + default: + throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `String.${name} result`) +} + +const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { + switch (name) { + case "isArray": + return Array.isArray(args[0]) + case "of": + return [...args] + case "from": { + if (args.length > 1) { + throw new InterpreterRuntimeError( + "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + // Map/Set materialize directly (the data checkpoint would serialize them to {}). + if (args[0] instanceof SandboxMap) + return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) + if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) + if (args[0] instanceof SandboxURLSearchParams) { + return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) + } + const source = boundedData(args[0], "Array.from input") + if (typeof source === "string") return Array.from(source) + if (Array.isArray(source)) return [...source] + if ( + source !== null && + typeof source === "object" && + typeof (source as { length?: unknown }).length === "number" + ) { + return Array.from(source as ArrayLike) + } + throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) + } + default: + throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) + } +} + +const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { + if (ref.namespace === "console") + throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) + if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) + if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) + if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) + if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) + if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) + if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node) + if (ref.namespace === "Date") { + if (!dateStatics.has(ref.name)) + throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) + return invokeDateStatic(ref.name, args, node) + } + if ( + ref.namespace === "RegExp" || + ref.namespace === "Map" || + ref.namespace === "Set" || + ref.namespace === "URLSearchParams" + ) { + throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) + } + return invokeJsonMethod(ref.name, args, node) +} + +// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. +const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { + switch (pattern.type) { + case "Identifier": + out.push(getString(pattern, "name")) + break + case "AssignmentPattern": + collectPatternNames(getNode(pattern, "left"), out) + break + case "RestElement": + collectPatternNames(getNode(pattern, "argument"), out) + break + case "ArrayPattern": + for (const element of getArray(pattern, "elements")) { + if (element !== null) collectPatternNames(asNode(element, "elements"), out) + } + break + case "ObjectPattern": + for (const property of getArray(pattern, "properties")) { + const prop = asNode(property, "properties") + collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out) + } + break + } + return out +} + +class Interpreter { + private scopes: Array> + private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect + // Enumerable namespace/tool names at a node of the host tool tree, threaded from + // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. + private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray + private readonly logs: Array + private lastValue: unknown + // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). + private readonly callPermits: Semaphore.Semaphore + // Fiber-backed promises whose settlement no program construct has observed yet. Successful + // program completion drains these (like a runtime waiting on in-flight work at exit) and + // surfaces a never-awaited failure as an unhandled-rejection diagnostic. + private readonly pendingSettlements = new Set() + + constructor( + invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, + toolKeys: (path: ReadonlyArray) => ReadonlyArray, + logs: Array = [], + ) { + const globalScope = new Map() + this.scopes = [globalScope] + this.invokeTool = invokeTool + this.toolKeys = toolKeys + this.logs = logs + this.lastValue = undefined + this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) + globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) + globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) + globalScope.set("undefined", { mutable: false, value: undefined }) + globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) + globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) + globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) + globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) + globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) + globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) + globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) + globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") }) + globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) + globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) + globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") }) + globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) + globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) + globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) + globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") }) + globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") }) + globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") }) + globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") }) + globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") }) + globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") }) + // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` + // (with or without `new`) constructs a branded { name, message } error object. + for (const name of errorConstructors) { + globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) + } + // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data + // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. + globalScope.set("NaN", { mutable: false, value: NaN }) + globalScope.set("Infinity", { mutable: false, value: Infinity }) + } + + run(program: ProgramNode): Effect.Effect { + const self = this + // Run the program body in its own module scope on top of the builtin global scope, so + // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like + // JS module scope, instead of colliding with the seeded globals. + this.pushScope() + return Effect.gen(function* () { + self.hoistFunctions(program.body) + let value: unknown = undefined + let returned = false + for (const statement of program.body) { + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "return") { + value = result.value + returned = true + break + } + + if (result.kind === "break" || result.kind === "continue") { + throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + if (!returned) value = self.lastValue + + // The program body runs inside an implicit async function, so a returned promise + // resolves before crossing the data boundary - `return tools.ns.tool(...)` works + // without an explicit await, exactly as in JS. + if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) + yield* self.drainPendingSettlements() + return value + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + // Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so + // their work completes before the execution ends - mirroring a JS runtime waiting on + // in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection + // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). + private drainPendingSettlements(): Effect.Effect { + const self = this + return Effect.gen(function* () { + for (const promise of [...self.pendingSettlements]) { + const exit = yield* self.observePromise(promise) + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue + const failure = normalizeError(Cause.squash(exit.cause)) + throw new InterpreterRuntimeError( + `Unhandled rejection from an un-awaited tool call: ${failure.message}`, + undefined, + failure.kind, + ["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."], + ) + } + }) + } + + // Eagerly starts a tool call on a supervised child fiber (so the execution timeout and + // scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a + // first-class promise value. `startImmediately` makes the runtime admit the call - charging + // the tool-call budget and firing onToolCallStart - at the call site, before any await. + private createToolCallPromise( + path: ReadonlyArray, + args: Array, + ): Effect.Effect { + const self = this + return Effect.map( + Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), { + startImmediately: true, + }), + (fiber) => { + const promise = new SandboxPromise(fiber) + self.pendingSettlements.add(promise) + return promise + }, + ) + } + + // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking. + // Fiber settlement is idempotent, so observing the same promise repeatedly (await twice, + // Promise.all([p, p])) never re-runs the underlying call. + private observePromise(promise: SandboxPromise): Effect.Effect> { + this.pendingSettlements.delete(promise) + return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void) + } + + // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch + // observes it exactly like a synchronous throw at the await site. + private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect { + const self = this + return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node)) + } + + private unwrapPromiseExit( + promise: SandboxPromise | undefined, + exit: Exit.Exit, + node?: AstNode, + ): Effect.Effect { + if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) + // A call Promise.race interrupted after losing settles as a catchable program failure; + // any other interruption is execution teardown (timeout/host) and must keep propagating + // as interruption rather than becoming program-visible data. + if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) { + return Effect.fail( + new InterpreterRuntimeError( + "This tool call was interrupted because another value settled a Promise.race first.", + node, + ), + ) + } + return Effect.failCause(exit.cause) + } + + private evaluateStatement(node: AstNode): Effect.Effect { + switch (node.type) { + case "ExpressionStatement": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + case "VariableDeclaration": + return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) + case "ReturnStatement": { + const argumentNode = getOptionalNode(node, "argument") + return argumentNode + ? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value })) + : Effect.succeed({ kind: "return", value: undefined }) + } + case "BlockStatement": + return this.evaluateBlock(node) + case "IfStatement": + return this.evaluateIfStatement(node) + case "SwitchStatement": + return this.evaluateSwitchStatement(node) + case "WhileStatement": + return this.evaluateWhileStatement(node) + case "DoWhileStatement": + return this.evaluateDoWhileStatement(node) + case "ForStatement": + return this.evaluateForStatement(node) + case "ForOfStatement": + return this.evaluateForOfStatement(node) + case "ForInStatement": + return this.evaluateForInStatement(node) + case "BreakStatement": + return Effect.succeed(this.evaluateBreakStatement(node)) + case "ContinueStatement": + return Effect.succeed(this.evaluateContinueStatement(node)) + case "ThrowStatement": + return this.evaluateThrowStatement(node) + case "TryStatement": + return this.evaluateTryStatement(node) + case "EmptyStatement": + return Effect.succeed({ kind: "none" }) + case "FunctionDeclaration": + return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateBlock(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function* () { + const body = getArray(node, "body") + self.hoistFunctions(body) + + for (const statementValue of body) { + const statement = asNode(statementValue, "body") + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "value") { + self.lastValue = result.value + continue + } + + if (result.kind !== "none") { + return result + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private createFunction(node: AstNode): CodeModeFunction { + if (node.generator === true) { + throw new InterpreterRuntimeError( + "Generator functions are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + return new CodeModeFunction( + getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), + getNode(node, "body"), + this.scopes.slice(), + ) + } + + // Function declarations are hoisted: bound in their scope before the body runs, so a + // program can call a helper defined further down (matching JavaScript). + private hoistFunctions(statements: Array): void { + for (const statementValue of statements) { + if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue + const node = statementValue as AstNode + this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) + } + } + + private evaluateIfStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const consequentNode = getNode(node, "consequent") + const alternateNode = getOptionalNode(node, "alternate") + + return Effect.flatMap(this.evaluateExpression(testNode), (test) => + test + ? this.evaluateStatement(consequentNode) + : alternateNode + ? this.evaluateStatement(alternateNode) + : Effect.succeed({ kind: "none" }), + ) + } + + private evaluateSwitchStatement(node: AstNode): Effect.Effect { + const self = this + this.pushScope() + return Effect.gen(function* () { + const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) + if (containsOpaqueReference(discriminant)) { + throw new InterpreterRuntimeError( + "Switch discriminants must be data values in CodeMode.", + node, + "InvalidDataValue", + ) + } + const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) + let defaultIndex: number | undefined + let selected: number | undefined + for (const [index, branch] of cases.entries()) { + const test = getOptionalNode(branch, "test") + if (!test) { + defaultIndex = index + continue + } + const candidate = yield* self.evaluateExpression(test) + if (containsOpaqueReference(candidate)) { + throw new InterpreterRuntimeError( + "Switch case values must be data values in CodeMode.", + test, + "InvalidDataValue", + ) + } + if (candidate === discriminant) { + selected = index + break + } + } + const start = selected ?? defaultIndex + if (start === undefined) return { kind: "none" } satisfies StatementResult + for (let index = start; index < cases.length; index += 1) { + for (const statementValue of getArray(cases[index]!, "consequent")) { + const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) + if (result.kind === "break") return { kind: "none" } satisfies StatementResult + if (result.kind === "return" || result.kind === "continue") return result + if (result.kind === "value") self.lastValue = result.value + } + } + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateWhileStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const bodyNode = getNode(node, "body") + + const self = this + return Effect.gen(function* () { + while (yield* self.evaluateExpression(testNode)) { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateDoWhileStatement(node: AstNode): Effect.Effect { + const bodyNode = getNode(node, "body") + const testNode = getNode(node, "test") + + const self = this + return Effect.gen(function* () { + do { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } while (yield* self.evaluateExpression(testNode)) + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateForStatement(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function* () { + const initNode = getOptionalNode(node, "init") + const testNode = getOptionalNode(node, "test") + const updateNode = getOptionalNode(node, "update") + const bodyNode = getNode(node, "body") + + if (initNode) { + if (initNode.type === "VariableDeclaration") { + yield* self.evaluateVariableDeclaration(initNode) + } else { + yield* self.evaluateExpression(initNode) + } + } + + const perIterationBindings = + initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" + ? Array.from(self.currentScope().keys()) + : [] + + while (testNode ? yield* self.evaluateExpression(testNode) : true) { + let iterationScope: Map | undefined + if (perIterationBindings.length > 0) { + iterationScope = new Map( + perIterationBindings.map((name) => { + const binding = self.currentScope().get(name)! + return [name, { ...binding }] + }), + ) + self.scopes.push(iterationScope) + } + const result = yield* self.evaluateStatement(bodyNode).pipe( + Effect.ensuring( + Effect.sync(() => { + if (iterationScope) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (iterationScope) { + const loopScope = self.currentScope() + for (const name of perIterationBindings) { + loopScope.set(name, { ...iterationScope.get(name)! }) + } + } + + if (updateNode) { + yield* self.evaluateExpression(updateNode) + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateForOfStatement(node: AstNode): Effect.Effect { + if (getBoolean(node, "await")) { + throw new InterpreterRuntimeError("for await...of is not supported.", node) + } + + const self = this + return Effect.gen(function* () { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + // Arrays iterate in place; strings iterate code points; Maps iterate [key, value] + // pairs and Sets iterate values over a snapshot (mutation during iteration is safe). + const iterable = Array.isArray(right) ? right : spreadItems(right) + if (iterable === undefined) { + throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) + } + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...of supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...of binding.", left) + } + + for (const value of iterable) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, value, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring( + Effect.sync(() => { + if (declaration) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + // Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool + // references: plain data objects enumerate their own keys, arrays their index strings (plus + // any own non-index properties, e.g. match results' index/groups - exactly Object.keys in + // JS), and a tool reference the namespace/tool names at its path in the host tool tree. + // Returns undefined for everything else so callers can raise a contextual error. + private enumerableKeys(value: unknown): Array | undefined { + if (value instanceof ToolReference) { + return [...this.toolKeys(value.path)] + } + if (Array.isArray(value)) { + return Object.keys(value) + } + if (value !== null && typeof value === "object" && !isRuntimeReference(value)) { + return Object.keys(value) + } + return undefined + } + + private evaluateForInStatement(node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function* () { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + // Keys are snapshotted up front (mutation during iteration is safe): plain objects + // enumerate their own keys, arrays their index strings, and tool references the + // namespace/tool names at that node - the same enumeration Object.keys performs. + // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather + // than real JS's surprising behavior (indices for strings, zero iterations for + // Maps/Sets/null): the hint points at the constructs that do what the program means. + const keys = self.enumerableKeys(right) + if (keys === undefined) { + throw new InterpreterRuntimeError( + "for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.", + node, + ) + } + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...in supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...in binding.", left) + } + + for (const key of keys) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, key, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring( + Effect.sync(() => { + if (declaration) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + private evaluateBreakStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node) + } + + return { kind: "break" } + } + + private evaluateContinueStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node) + } + + return { kind: "continue" } + } + + private evaluateThrowStatement(node: AstNode): Effect.Effect { + const argument = getNode(node, "argument") + return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value))) + } + + private evaluateTryStatement(node: AstNode): Effect.Effect { + const body = getNode(node, "block") + const handler = getOptionalNode(node, "handler") + const finalizer = getOptionalNode(node, "finalizer") + const self = this + + const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { + onFailure: (cause) => { + if (cause.reasons.some(Cause.isInterruptReason) || !handler) { + return Effect.failCause(cause) + } + + // The program sees a plain { message } error (or the thrown value itself) - see + // caughtErrorValue, shared with Promise.allSettled rejection reasons. + const caught = caughtErrorValue(Cause.squash(cause)) + const parameter = getOptionalNode(handler, "param") + self.pushScope() + return Effect.gen(function* () { + if (parameter) yield* self.declarePattern(parameter, caught, true, handler) + return yield* self.evaluateStatement(getNode(handler, "body")) + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }, + onSuccess: Effect.succeed, + }) + + if (!finalizer) return attempted + + const isAbrupt = (result: StatementResult): boolean => + result.kind === "return" || result.kind === "break" || result.kind === "continue" + + return Effect.matchCauseEffect(attempted, { + onFailure: (cause) => + cause.reasons.some(Cause.isInterruptReason) + ? Effect.failCause(cause) + : Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause), + ), + onSuccess: (result) => + Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result), + ), + }) + } + + private evaluateVariableDeclaration(node: AstNode): Effect.Effect { + const kind = getString(node, "kind") + const declarations = getArray(node, "declarations") + const self = this + return Effect.gen(function* () { + for (const declarationValue of declarations) { + const declaration = asNode(declarationValue, "declarations") + + if (declaration.type !== "VariableDeclarator") { + throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration) + } + + const init = getOptionalNode(declaration, "init") + const value = init ? yield* self.evaluateExpression(init) : undefined + yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration) + } + }) + } + + private declarePattern( + pattern: AstNode, + value: unknown, + mutable: boolean, + node: AstNode, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + if (pattern.type === "Identifier") { + self.declare(getString(pattern, "name"), value, mutable, node) + return + } + + // Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined. + if (pattern.type === "AssignmentPattern") { + const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value + yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) + return + } + + if (pattern.type === "ObjectPattern") { + if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { + throw new InterpreterRuntimeError( + "Object destructuring requires a data object value.", + pattern, + "InvalidDataValue", + ) + } + + const consumed = new Set() + for (const propertyValue of getArray(pattern, "properties")) { + const property = asNode(propertyValue, "properties") + + // Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys. + if (property.type === "RestElement") { + const rest: SafeObject = Object.create(null) as SafeObject + for (const [key, item] of Object.entries(value as SafeObject)) { + if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item + } + yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property) + continue + } + + if ( + property.type !== "Property" || + getBoolean(property, "computed") || + getString(property, "kind") !== "init" + ) { + throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) + if (isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode) + } + consumed.add(key) + yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property) + } + return + } + + if (pattern.type === "ArrayPattern") { + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) + } + + for (const [index, item] of getArray(pattern, "elements").entries()) { + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + // Array rest: `[head, ...tail]` - binds the remaining elements (must be last). + if (element.type === "RestElement") { + yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) + break + } + yield* self.declarePattern(element, value[index], mutable, pattern) + } + return + } + + throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) + }) + } + + private evaluateExpression(node: AstNode): Effect.Effect { + switch (node.type) { + case "Literal": { + // A regex literal parses as a Literal node carrying { pattern, flags }; construct the + // sandbox regex from those (the host `value` instance is never exposed). + const regex = node.regex + if (isRecord(regex) && typeof regex.pattern === "string") { + return Effect.sync(() => + this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node), + ) + } + return Effect.sync(() => boundedData(node.value, "Literal")) + } + case "Identifier": + return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) + case "BinaryExpression": + return this.evaluateBinaryExpression(node) + case "LogicalExpression": + return this.evaluateLogicalExpression(node) + case "UnaryExpression": + return this.evaluateUnaryExpression(node) + case "AssignmentExpression": + return this.evaluateAssignmentExpression(node) + case "CallExpression": + return this.evaluateCallExpression(node) + case "ArrowFunctionExpression": + case "FunctionExpression": + return Effect.sync(() => this.createFunction(node)) + case "MemberExpression": + return this.readMember(node) + case "ChainExpression": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => + value === OptionalShortCircuit ? undefined : value, + ) + case "ObjectExpression": + return this.evaluateObjectExpression(node) + case "ArrayExpression": + return this.evaluateArrayExpression(node) + case "TemplateLiteral": + return this.evaluateTemplateLiteral(node) + case "ConditionalExpression": + return this.evaluateConditionalExpression(node) + case "UpdateExpression": + return this.evaluateUpdateExpression(node) + case "AwaitExpression": { + // `await` resolves a promise value; awaiting anything else is a passthrough no-op, + // matching real JS semantics for non-thenables. + const self = this + return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => + value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value), + ) + } + case "NewExpression": + return this.evaluateNewExpression(node) + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateNewExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + if (callee.type !== "Identifier") { + throw unsupportedSyntax("NewExpression", node) + } + const name = getString(callee, "name") + const argNodes = getArray(node, "arguments") + const self = this + if (name === "Promise") { + throw new InterpreterRuntimeError( + "new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + if (errorConstructors.has(name)) { + return Effect.gen(function* () { + const arg = + argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined + return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) + }) + } + if (valueConstructors.has(name)) { + return Effect.gen(function* () { + const args = yield* self.evaluateCallArguments(argNodes) + switch (name) { + case "Date": + return self.constructDate(args) + case "RegExp": + return self.constructRegExp(args, node) + case "Map": + return self.constructMap(args[0], node) + case "Set": + return self.constructSet(args[0], node) + case "URL": + return self.constructURL(args, node) + default: + return self.constructURLSearchParams(args[0], node) + } + }) + } + throw unsupportedSyntax("NewExpression", node) + } + + private constructDate(args: Array): SandboxDate { + if (args.length === 0) return new SandboxDate(Date.now()) + if (args.length === 1) { + const arg = args[0] + if (arg instanceof SandboxDate) return new SandboxDate(arg.time) + if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime()) + if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) + return new SandboxDate(Number.NaN) + } + // new Date(year, month, day?, hours?, ...) - local-time component form. + const parts = args.map((arg) => coerceToNumber(arg)) + return new SandboxDate(new Date(...(parts as [number, number])).getTime()) + } + + private constructRegExp(args: Array, node: AstNode): SandboxRegExp { + const first = args[0] + const pattern = + first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first) + const flagsArg = args[1] + if (flagsArg !== undefined && typeof flagsArg !== "string") { + throw new InterpreterRuntimeError( + `RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`, + node, + ) + } + const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "") + try { + return new SandboxRegExp(pattern, flags) + } catch (error) { + // Say which part was rejected and how to fix it, instead of passing the engine + // message through bare. A flags failure names the flags; a pattern failure gets the + // escaping hint (the usual cause is an unescaped metacharacter in a built-up string). + const reason = regexFailureReason(error) + throw new InterpreterRuntimeError( + /flag/i.test(reason) + ? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.` + : `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`, + node, + ).as("SyntaxError") + } + } + + private constructMap(init: unknown, node: AstNode): SandboxMap { + const target = new SandboxMap() + if (init === undefined || init === null) return target + const entries = Array.isArray(init) + ? init + : init instanceof SandboxMap + ? Array.from(init.map.entries(), ([key, item]): Array => [key, item]) + : undefined + if (entries === undefined) { + throw new InterpreterRuntimeError( + "new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", + node, + ) + } + for (const pair of entries) { + if (!Array.isArray(pair)) { + throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node) + } + target.map.set(pair[0], pair[1]) + } + return target + } + + private constructSet(init: unknown, node: AstNode): SandboxSet { + const target = new SandboxSet() + if (init === undefined || init === null) return target + const items = Array.isArray(init) + ? init + : init instanceof SandboxSet + ? Array.from(init.set.values()) + : typeof init === "string" + ? Array.from(init) + : undefined + if (items === undefined) { + throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node) + } + for (const item of items) target.set.add(item) + return target + } + + private constructURL(args: Array, node: AstNode): SandboxURL { + if (args.length === 0) { + throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as( + "TypeError", + ) + } + const input = urlArgument(args[0], "new URL input") + const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base") + try { + return new SandboxURL(new URL(input, base)) + } catch { + throw new InterpreterRuntimeError( + `new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`, + node, + ).as("TypeError") + } + } + + private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams { + if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams()) + if (init instanceof SandboxURLSearchParams) { + return new SandboxURLSearchParams(new URLSearchParams(init.params)) + } + if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init)) + if (init === null || typeof init === "number" || typeof init === "boolean") { + return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init))) + } + if (init instanceof SandboxMap) { + return this.constructURLSearchParams( + Array.from(init.map.entries(), ([key, value]) => [key, value]), + node, + ) + } + if (Array.isArray(init)) { + const entries = init.map((pair) => { + if (!Array.isArray(pair) || pair.length !== 2) { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects an array of [name, value] pairs.", + node, + ).as("TypeError") + } + return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [ + string, + string, + ] + }) + return new SandboxURLSearchParams(new URLSearchParams(entries)) + } + if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams()) + const data = boundedData(init, "new URLSearchParams input") + if (data === null || typeof data !== "object") { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.", + node, + ).as("TypeError") + } + return new SandboxURLSearchParams( + new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))), + ) + } + + private evaluateBinaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const self = this + return Effect.gen(function* () { + const lhs = yield* self.evaluateExpression(getNode(node, "left")) + const rhs = yield* self.evaluateExpression(getNode(node, "right")) + // Like `typeof`, `instanceof` observes any value without coercing it (a promise or + // function operand is a legitimate question, not an error), so it is handled before + // the data-only operand check. + if (operator === "instanceof") return instanceofValue(lhs, rhs, node) + return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result") + }) + } + + /** + * Applies a binary operator to two already-evaluated operands with CodeMode's coercion + * semantics. Shared by binary expressions and compound assignment (`x op= y` must behave + * exactly like `x = x op y`, coercion included). + */ + private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { + if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { + throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") + } + // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host + // "No default value" TypeError when an operator coerces them. Coerce to their JS string + // form first (as String(x) / template literals do) so operators behave like JavaScript. + // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value + // for arithmetic and ordering - so `end - start` and `a < b` work as in JS. + // Identity (=== / !==) and the right operand of `in` keep their raw object value. + const coerceOperand = (operand: unknown): unknown => { + if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time + return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand + } + const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" + const l = coerceOperand(lhs) + const r = coerceOperand(rhs) + switch (operator) { + case "+": + return (l as string) + (r as string) + case "-": + return (l as number) - (r as number) + case "*": + return (l as number) * (r as number) + case "/": + return (l as number) / (r as number) + case "%": + return (l as number) % (r as number) + case "**": + return (l as number) ** (r as number) + // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. + case "==": + return bothObjects ? lhs === rhs : l == r + case "===": + return lhs === rhs + case "!=": + return bothObjects ? lhs !== rhs : l != r + case "!==": + return lhs !== rhs + case "<": + return (l as string) < (r as string) + case "<=": + return (l as string) <= (r as string) + case ">": + return (l as string) > (r as string) + case ">=": + return (l as string) >= (r as string) + case "&": + return (l as number) & (r as number) + case "|": + return (l as number) | (r as number) + case "^": + return (l as number) ^ (r as number) + case "<<": + return (l as number) << (r as number) + case ">>": + return (l as number) >> (r as number) + case ">>>": + return (l as number) >>> (r as number) + case "in": + if (rhs === null || typeof rhs !== "object") { + throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) + } + // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). + return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) + default: + throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) + } + } + + private evaluateLogicalExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => { + if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left) + if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) + if (operator === "??") + return left !== null && left !== undefined + ? Effect.succeed(left) + : this.evaluateExpression(getNode(node, "right")) + throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node) + }) + } + + private evaluateUnaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so + // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before + // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. + if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { + return Effect.succeed("undefined") + } + return Effect.map(this.evaluateExpression(argument), (value) => { + // `typeof` and `!` never throw in JS - they observe any value (functions and runtime + // references included) without coercing it, so feature detection and negation work. + if (operator === "typeof") return typeofValue(value) + if (operator === "!") return !value + if (containsOpaqueReference(value)) { + throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") + } + // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value + // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to + // their JS string form first (see evaluateBinaryExpression). + const operand = + value instanceof SandboxDate + ? value.time + : value !== null && typeof value === "object" + ? coerceToString(value) + : value + let result: unknown + switch (operator) { + case "+": + result = +(operand as number) + break + case "-": + result = -(operand as number) + break + case "~": + result = ~(operand as number) + break + default: + throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) + } + return boundedData(result, "Unary expression result") + }) + } + + private evaluateAssignmentExpression(node: AstNode): Effect.Effect { + const left = getNode(node, "left") + const operator = getString(node, "operator") + const self = this + return Effect.gen(function* () { + if (operator === "??=" || operator === "||=" || operator === "&&=") { + return yield* self.evaluateLogicalAssignment(node, left, operator) + } + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + if (left.type === "Identifier") { + const name = getString(left, "name") + if (operator === "=") return self.setIdentifierValue(name, rightValue, left) + const next = boundedData( + self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), + "Assignment result", + ) + return self.setIdentifierValue(name, next, left) + } + if (left.type === "MemberExpression") { + if (operator === "=") return yield* self.writeMember(left, rightValue) + return yield* self.modifyMember(left, (current) => { + const next = boundedData( + self.applyCompoundAssignment(operator, current, rightValue, node), + "Assignment result", + ) + return Effect.succeed({ write: true, next, result: next }) + }) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + }) + } + + private evaluateLogicalAssignment( + node: AstNode, + left: AstNode, + operator: string, + ): Effect.Effect { + const self = this + const shouldAssign = (current: unknown): boolean => + operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current) + if (left.type === "Identifier") { + const name = getString(left, "name") + return Effect.gen(function* () { + const current = self.getIdentifierValue(name, left) + if (!shouldAssign(current)) return current + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + return self.setIdentifierValue(name, rightValue, left) + }) + } + if (left.type === "MemberExpression") { + // Resolve the member exactly once; evaluate the RHS only if we actually assign. + return self.modifyMember(left, (current) => + shouldAssign(current) + ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ + write: true, + next: rightValue, + result: rightValue, + })) + : Effect.succeed({ write: false, next: current, result: current }), + ) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + } + + private evaluateUpdateExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + const prefix = getBoolean(node, "prefix") + + const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined + + if (increment === undefined) { + throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node) + } + + if (argument.type === "Identifier") { + return Effect.sync(() => { + const name = getString(argument, "name") + const current = Number(this.getIdentifierValue(name, argument)) + const next = current + increment + this.setIdentifierValue(name, next, argument) + return prefix ? next : current + }) + } + + if (argument.type === "MemberExpression") { + return this.modifyMember(argument, (current) => { + const value = Number(current) + const next = value + increment + return Effect.succeed({ write: true, next, result: prefix ? next : value }) + }) + } + + throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument) + } + + private evaluateCallExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + const argNodes = getArray(node, "arguments") + + const self = this + return Effect.gen(function* () { + const callable = yield* self.evaluateExpression(callee) + if (callable === OptionalShortCircuit) return OptionalShortCircuit + if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit + + const args = yield* self.evaluateCallArguments(argNodes) + + if (callable instanceof ToolReference) { + if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) + // An un-awaited tool call is a first-class promise value; the call itself starts now. + return yield* self.createToolCallPromise(callable.path, args) + } + if (callable instanceof PromiseMethodReference) { + return yield* self.invokePromiseMethod(callable, args, node) + } + if (callable instanceof CodeModeFunction) { + return yield* self.invokeFunction(callable, args) + } + if (callable instanceof IntrinsicReference) { + return yield* self.invokeIntrinsic(callable, args, node) + } + if (callable instanceof GlobalMethodReference) { + if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) + if (callable.namespace === "Object" && args[0] instanceof ToolReference) { + return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) + } + return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) + } + if (callable instanceof CoercionFunction) { + return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`) + } + if (callable instanceof UriFunction) { + return invokeUriFunction(callable, args, node) + } + // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS. + if (callable instanceof ErrorConstructorReference) { + return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) + } + throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) + }) + } + + // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate + // namespace/tool names from the host tool tree - the discovery idiom a model reaches for + // first. Every other Object helper cannot produce data from a tool reference, so it fails + // with a pointer at the working idioms instead of the generic plain-objects-only message. + private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { + if (name === "keys") { + return boundedData(this.enumerableKeys(ref)!, "Object.keys result") + } + throw new InterpreterRuntimeError( + `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + node, + "InvalidDataValue", + ) + } + + private invokeConsole(name: string, args: Array, node: AstNode): undefined { + if (!consoleMethods.has(name)) + throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) + this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node))) + return undefined + } + + private formatConsoleMessage(name: string, args: Array, node: AstNode): string { + if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0]) + if (name === "table") return this.formatConsoleTable(args[0], args[1], node) + const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" + return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}` + } + + // Console arguments format deeply and totally: values render as a debugger would show them + // rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox + // values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...], + // Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place, + // and plain objects/arrays render JSON-style. Formatting never fails the program: cycles + // render "[Circular]" and extreme depth degrades to "...". + private formatConsoleArgument(value: unknown): string { + if (value === undefined) return "undefined" + // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue). + if (typeof value === "string") return value + return this.formatConsoleValue(value, new Set(), 0) + } + + private formatConsoleValue(value: unknown, seen: Set, depth: number): string { + // Nested undefined renders as null, matching what JSON boundary output would show. + if (value === null || value === undefined) return "null" + if (typeof value === "string") return JSON.stringify(value) + // String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form. + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (typeof value !== "object") return String(value) + if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" + if (value instanceof SandboxDate) return coerceToString(value) + if (value instanceof SandboxRegExp) return coerceToString(value) + if (value instanceof SandboxURL) return coerceToString(value) + if (value instanceof SandboxURLSearchParams) return coerceToString(value) + if (depth > MAX_CONSOLE_DEPTH) return "..." + if (seen.has(value)) return "[Circular]" + if (value instanceof SandboxMap) { + seen.add(value) + try { + const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) + return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (value instanceof SandboxSet) { + seen.add(value) + try { + return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (isRuntimeReference(value)) return "[CodeMode reference]" + seen.add(value) + try { + if (Array.isArray(value)) { + return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]` + } + return `{${Object.entries(value) + .map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`) + .join(",")}}` + } finally { + seen.delete(value) + } + } + + private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { + if (value === undefined) return "undefined" + // Sandbox values are legitimate table data (cells render their friendly forms); only + // truly opaque references (functions, tools, promises) collapse to the marker. + if (containsOpaqueReference(value)) return "[CodeMode reference]" + const data = boundedData(value, "console.table argument") + const columns = this.consoleTableColumns(columnsArgument, node) + const rows = this.consoleTableRows(data, columns) + const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) + const header = ["(index)", ...keys].join("\t") + return [ + header, + ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")), + ].join("\n") + } + + private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { + if (value === undefined) return undefined + if (containsRuntimeReference(value)) return undefined + const columns = copyOut(copyIn(value, "console.table columns"), true) + return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined + } + + private consoleTableRows( + data: unknown, + columns: ReadonlyArray | undefined, + ): Array<{ readonly index: string; readonly values: Record }> { + if (Array.isArray(data)) { + return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) + } + if (data !== null && typeof data === "object" && !isSandboxValue(data)) { + return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) })) + } + return [{ index: "0", values: { Value: data } }] + } + + private consoleTableValues(value: unknown, columns: ReadonlyArray | undefined): Record { + if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { + const source = value as Record + if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) + return Object.fromEntries(Object.entries(source)) + } + return { Value: value } + } + + private formatConsoleTableCell(value: unknown): string { + if (value === undefined) return "" + if (typeof value === "string") return value + return this.formatConsoleValue(value, new Set(), 0) + } + + private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { + const self = this + return Effect.gen(function* () { + const args: Array = [] + for (const [index, arg] of argNodes.entries()) { + const argNode = asNode(arg, `arguments[${index}]`) + if (argNode.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) + const items = spreadItems(spread) + if (items === undefined) + throw new InterpreterRuntimeError( + "Spread arguments require an array, string, Map, or Set in CodeMode.", + argNode, + ) + args.push(...items) + } else { + args.push(yield* self.evaluateExpression(argNode)) + } + } + return args + }) + } + + // Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable + // collection) mixing promise values and plain data - built inline, beforehand, via spread, + // whatever - because tool calls already run eagerly on their own fibers; the combinators + // only observe settlements. Joining is therefore sequential (no extra fibers) without + // costing parallelism, and the concurrency cap stays where the work is: the fork semaphore. + private invokePromiseMethod( + ref: PromiseMethodReference, + args: Array, + node: AstNode, + ): Effect.Effect { + const self = this + if (ref.name === "resolve") { + // Promise.resolve of a promise is that promise (JS flattens); anything else is a + // promise already fulfilled with the value. + const value = args[0] + return Effect.succeed( + value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)), + ) + } + if (ref.name === "reject") { + return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0])))) + } + + const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0]) + if (items === undefined) { + throw new InterpreterRuntimeError( + `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, + node, + ) + } + + switch (ref.name) { + case "all": { + // Mark every promise element observed up-front (Promise.all handles all of its + // members' failures, as in JS), then join in index order; the first failure rejects + // the whole call while unrelated in-flight members keep running. + const settles = items.map((item) => + item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item), + ) + return Effect.gen(function* () { + const values: Array = [] + for (const settle of settles) values.push(yield* settle) + return values + }) + } + case "allSettled": { + const observations = items.map((item) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit })) + : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }), + ) + return Effect.gen(function* () { + const outcomes: Array = [] + for (const observation of observations) { + const { exit, promise } = yield* observation + if (Exit.isSuccess(exit)) { + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), + ) + continue + } + const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause) + if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) { + // Execution teardown (timeout/host interruption), not a program-level rejection. + return yield* Effect.failCause(exit.cause) + } + const thrown = raceInterrupted + ? new InterpreterRuntimeError( + "This tool call was interrupted because another value settled a Promise.race first.", + node, + ) + : Cause.squash(exit.cause) + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(thrown), + }), + ) + } + return outcomes + }) + } + case "race": { + if (items.length === 0) { + throw new InterpreterRuntimeError( + "Promise.race([]) would never settle; provide at least one promise or value.", + node, + ) + } + const observations = items.map((item, index) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ index, exit })) + : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }), + ) + return Effect.gen(function* () { + // First settlement (fulfilled OR rejected) wins; the observations never fail, so + // racing them yields exactly that. Losing in-flight calls are then interrupted. + const winner = yield* Effect.raceAll(observations) + for (const [index, item] of items.entries()) { + if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue + item.interrupted = true + yield* Fiber.interrupt(item.fiber) + } + const winningItem = items[winner.index] + return yield* self.unwrapPromiseExit( + winningItem instanceof SandboxPromise ? winningItem : undefined, + winner.exit, + node, + ) + }) + } + } + } + + private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { + const self = this + return Effect.suspend(() => { + const savedScopes = self.scopes + self.scopes = [...fn.capturedScopes, new Map()] + const run = Effect.gen(function* () { + // Seed every parameter name into the scope as a TDZ slot first, so a default that + // references another parameter resolves to that (uninitialized) param rather than + // silently falling through to an outer binding of the same name - matching JS. + const paramScope = self.currentScope() + for (const parameter of fn.parameters) { + for (const name of collectPatternNames(parameter)) { + paramScope.set(name, { mutable: true, value: undefined, initialized: false }) + } + } + for (const [index, parameter] of fn.parameters.entries()) { + if (parameter.type === "RestElement") { + yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) + break + } + yield* self.declarePattern(parameter, args[index], true, parameter) + } + + if (fn.body.type === "BlockStatement") { + const result = yield* self.evaluateStatement(fn.body) + return result.kind === "return" || result.kind === "value" ? result.value : undefined + } + + return yield* self.evaluateExpression(fn.body) + }) + return run.pipe( + Effect.ensuring( + Effect.sync(() => { + self.scopes = savedScopes + }), + ), + ) + }) + } + + private invokeIntrinsic( + ref: IntrinsicReference, + args: Array, + node: AstNode, + ): Effect.Effect { + if (typeof ref.receiver === "string") { + if ( + (ref.name === "replace" || ref.name === "replaceAll") && + (args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction) + ) { + return this.invokeStringReplacer(ref.receiver, ref.name, args, node) + } + return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) + } + if (typeof ref.receiver === "number") { + return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) + } + if (Array.isArray(ref.receiver)) { + return this.invokeArrayMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxDate) { + return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxRegExp) { + return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) + } + if (ref.receiver instanceof SandboxMap) { + return this.invokeMapMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxSet) { + return this.invokeSetMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxURL) { + return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxURLSearchParams) { + return this.invokeURLSearchParamsMethod(ref.receiver, ref.name, args, node) + } + throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) + } + + private invokeStringReplacer( + value: string, + name: "replace" | "replaceAll", + args: Array, + node: AstNode, + ): Effect.Effect { + const apply = this.applyCollectionCallback(args[1], `String.${name}`, node) + const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array }> = [] + const collect = (...callbackArgs: Array): string => { + const match = callbackArgs[0] + const groups = callbackArgs[callbackArgs.length - 1] + const hasGroups = groups !== null && typeof groups === "object" + const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)] + if (typeof match !== "string" || typeof offset !== "number") { + throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node) + } + if (hasGroups) { + const safeGroups: SafeObject = Object.create(null) as SafeObject + for (const [key, group] of Object.entries(groups)) { + if (!isBlockedMember(key)) safeGroups[key] = group + } + callbackArgs[callbackArgs.length - 1] = safeGroups + } + matches.push({ match, offset, args: callbackArgs }) + return match + } + + const pattern = args[0] + if (pattern instanceof SandboxRegExp) { + if (name === "replaceAll" && !pattern.regex.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + if (name === "replace") value.replace(pattern.regex, collect) + else value.replaceAll(pattern.regex, collect) + } else { + if (typeof pattern !== "string") { + throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node) + } + if (name === "replace") value.replace(pattern, collect) + else value.replaceAll(pattern, collect) + } + + return Effect.gen(function* () { + const output: Array = [] + let end = 0 + for (const match of matches) { + output.push( + value.slice(end, match.offset), + coerceToString(boundedData(yield* apply(match.args), `String.${name} replacer result`)), + ) + end = match.offset + match.match.length + } + output.push(value.slice(end)) + return boundedData(output.join(""), `String.${name} result`) + }) + } + + // Runs a collection callback accepting a user function or supported builtin callable, + // mirroring the array-method callback contract. + private applyCollectionCallback( + callback: unknown, + name: string, + node: AstNode, + ): (args: Array) => Effect.Effect { + if ( + !(callback instanceof CodeModeFunction) && + !(callback instanceof CoercionFunction) && + !(callback instanceof UriFunction) + ) { + throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) + } + return (callbackArgs) => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) + : callback instanceof UriFunction + ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) + : this.invokeFunction(callback, callbackArgs) + } + + private invokeMapMethod( + target: SandboxMap, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + switch (name) { + case "get": + return Effect.succeed(target.map.get(args[0])) + case "has": + return Effect.succeed(target.map.has(args[0])) + case "set": + return Effect.sync(() => { + target.map.set(args[0], args[1]) + return target + }) + case "delete": + return Effect.sync(() => target.map.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.map.clear() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.map.keys())) + case "values": + return Effect.sync(() => Array.from(target.map.values())) + case "entries": + return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) + case "forEach": { + const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) + return Effect.gen(function* () { + // Snapshot iteration, matching the array-method callback contract. + for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeSetMethod( + target: SandboxSet, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + switch (name) { + case "has": + return Effect.succeed(target.set.has(args[0])) + case "add": + return Effect.sync(() => { + target.set.add(args[0]) + return target + }) + case "delete": + return Effect.sync(() => target.set.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.set.clear() + return undefined + }) + case "keys": + case "values": + return Effect.sync(() => Array.from(target.set.values())) + case "entries": + return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) + case "forEach": { + const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) + return Effect.gen(function* () { + for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeURLSearchParamsMethod( + target: SandboxURLSearchParams, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`) + const requireArgs = (count: number): void => { + if (args.length < count) { + throw new InterpreterRuntimeError( + `URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`, + node, + ).as("TypeError") + } + } + switch (name) { + case "append": { + requireArgs(2) + return Effect.sync(() => { + target.params.append(arg(0), arg(1)) + return undefined + }) + } + case "delete": { + requireArgs(1) + return Effect.sync(() => { + if (args[1] !== undefined) target.params.delete(arg(0), arg(1)) + else target.params.delete(arg(0)) + return undefined + }) + } + case "get": + requireArgs(1) + return Effect.sync(() => target.params.get(arg(0))) + case "getAll": + requireArgs(1) + return Effect.sync(() => target.params.getAll(arg(0))) + case "has": + requireArgs(1) + return Effect.sync(() => + args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)), + ) + case "set": { + requireArgs(2) + return Effect.sync(() => { + target.params.set(arg(0), arg(1)) + return undefined + }) + } + case "sort": + return Effect.sync(() => { + target.params.sort() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.params.keys())) + case "values": + return Effect.sync(() => Array.from(target.params.values())) + case "entries": + return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array => [key, value])) + case "toString": + return Effect.sync(() => target.params.toString()) + case "forEach": { + requireArgs(1) + const apply = this.applyCollectionCallback(args[0], "URLSearchParams.forEach", node) + return Effect.gen(function* () { + for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeArrayMethod( + target: Array, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + const optNumber = (value: unknown, label: string): number | undefined => { + if (value === undefined) return undefined + if (typeof value !== "number") + throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) + return value + } + switch (name) { + case "join": { + if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { + throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) + } + const input = boundedData(target, "Array.join input") as Array + return Effect.succeed( + input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), + ) + } + case "includes": + if (args.length === 0 || args.length > 2) + throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) + return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) + case "indexOf": + return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) + case "lastIndexOf": + return Effect.succeed( + args[1] === undefined + ? target.lastIndexOf(args[0]) + : target.lastIndexOf(args[0], optNumber(args[1], "start index")), + ) + case "at": + return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) + case "slice": + return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) + case "concat": + return Effect.succeed(target.concat(...args)) + case "flat": + return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) + case "reverse": + return Effect.succeed([...target].reverse()) + case "sort": + case "toSorted": + return this.sortArray(target, args[0], node) + case "toReversed": + return Effect.succeed([...target].reverse()) + case "with": { + const index = optNumber(args[0], "index") ?? 0 + const resolved = index < 0 ? target.length + index : index + if (resolved < 0 || resolved >= target.length) { + throw new InterpreterRuntimeError("Array.with index is out of range.", node) + } + const copied = [...target] + copied[resolved] = args[1] + return Effect.succeed(copied) + } + case "push": { + // Validate before mutating (so no rollback is needed): inserting a container into + // itself would create a cycle no later walk could survive. + for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node) + target.push(...args) + return Effect.succeed(target.length) + } + case "unshift": { + for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node) + target.unshift(...args) + return Effect.succeed(target.length) + } + case "pop": + return Effect.succeed(target.pop()) + case "shift": + return Effect.succeed(target.shift()) + case "splice": { + // Mutates in place and returns the removed elements, exactly like JS: one argument + // removes to the end, an undefined delete count removes nothing. + if (args.length === 0) return Effect.succeed(target.splice(0, 0)) + const start = optNumber(args[0], "start") ?? 0 + if (args.length === 1) return Effect.succeed(target.splice(start)) + const deleteCount = optNumber(args[1], "delete count") ?? 0 + const inserted = args.slice(2) + for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node) + return Effect.succeed(target.splice(start, deleteCount, ...inserted)) + } + case "fill": { + this.rejectCircularInsertion(target, args[0], "Array.fill result", node) + return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) + } + case "copyWithin": + return Effect.succeed( + target.copyWithin( + optNumber(args[0], "target index") ?? 0, + optNumber(args[1], "start") ?? 0, + optNumber(args[2], "end"), + ), + ) + // keys/values/entries return arrays (not iterators), matching the Map/Set convention; + // they work with for...of and spread either way. + case "keys": + return Effect.succeed(Array.from(target.keys())) + case "values": + return Effect.succeed([...target]) + case "entries": + return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) + } + + const callback = args[0] + if ( + !(callback instanceof CodeModeFunction) && + !(callback instanceof CoercionFunction) && + !(callback instanceof UriFunction) + ) { + throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) + } + const self = this + // Accept a user function or supported builtin callable, so idioms such as + // `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins + // are synchronous; only CodeModeFunctions can await tool calls. + const apply = (callbackArgs: Array): Effect.Effect => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) + : callback instanceof UriFunction + ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) + : self.invokeFunction(callback, callbackArgs) + return Effect.gen(function* () { + // Iterate a snapshot taken at call time so a callback that mutates the array can't + // self-extend the loop - matching JS, where elements appended during iteration are not visited. + const items = target.slice() + switch (name) { + case "map": { + const values: Array = [] + for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) + return values + } + case "flatMap": { + const values: Array = [] + for (const [index, item] of items.entries()) { + const mapped = yield* apply([item, index, items]) + if (Array.isArray(mapped)) values.push(...mapped) + else values.push(mapped) + } + return values + } + case "filter": { + const values: Array = [] + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) values.push(item) + } + return values + } + case "find": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return item + } + return undefined + case "findIndex": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return index + } + return -1 + case "some": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return true + } + return false + case "every": + for (const [index, item] of items.entries()) { + if (!(yield* apply([item, index, items]))) return false + } + return true + case "forEach": + for (const [index, item] of items.entries()) yield* apply([item, index, items]) + return undefined + case "reduce": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = 0 + } else { + if (items.length === 0) + throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) + accumulator = items[0] + start = 1 + } + for (let index = start; index < items.length; index += 1) { + accumulator = yield* apply([accumulator, items[index], index, items]) + } + return accumulator + } + case "reduceRight": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = items.length - 1 + } else { + if (items.length === 0) + throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) + accumulator = items[items.length - 1] + start = items.length - 2 + } + for (let index = start; index >= 0; index -= 1) { + accumulator = yield* apply([accumulator, items[index], index, items]) + } + return accumulator + } + case "findLast": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* apply([items[index], index, items])) return items[index] + } + return undefined + case "findLastIndex": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* apply([items[index], index, items])) return index + } + return -1 + } + throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) + }) + } + + private sortArray( + target: Array, + comparator: unknown, + node: AstNode, + ): Effect.Effect, unknown, R> { + if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { + throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) + } + if (!(comparator instanceof CodeModeFunction)) { + return Effect.sync(() => + [...target].sort((a, b) => { + const left = coerceToString(a) + const right = coerceToString(b) + return left < right ? -1 : left > right ? 1 : 0 + }), + ) + } + const self = this + const mergeSort = (items: Array): Effect.Effect, unknown, R> => { + if (items.length <= 1) return Effect.succeed(items) + const midpoint = Math.floor(items.length / 2) + return Effect.gen(function* () { + const left = yield* mergeSort(items.slice(0, midpoint)) + const right = yield* mergeSort(items.slice(midpoint)) + const merged: Array = [] + let leftIndex = 0 + let rightIndex = 0 + while (leftIndex < left.length && rightIndex < right.length) { + // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host + // crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element. + const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) + if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) + else merged.push(right[rightIndex++]) + } + return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] + }) + } + // Per spec, undefined elements sort to the end and the comparator is never called on them. + const defined = target.filter((item) => item !== undefined) + const undefinedCount = target.length - defined.length + return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) + } + + private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { + const objectValue: Record = Object.create(null) as Record + const properties = getArray(node, "properties") + const self = this + return Effect.gen(function* () { + for (const propertyValue of properties) { + const property = asNode(propertyValue, "properties") + + if (property.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(property, "argument")) + // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common + // `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values + // have no own enumerable properties in JS, so they are no-ops too. + if (spread === null || spread === undefined || isSandboxValue(spread)) continue + if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { + throw new InterpreterRuntimeError( + "Object spread requires a data object in CodeMode.", + property, + "InvalidDataValue", + ) + } + for (const [key, value] of Object.entries(spread)) { + if (isBlockedMember(key)) + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property) + objectValue[key] = value + } + continue + } + + if (property.type !== "Property") { + throw new InterpreterRuntimeError("Only standard object properties are supported.", property) + } + + if (getString(property, "kind") !== "init") { + throw new InterpreterRuntimeError("Only init object properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const valueNode = getNode(property, "value") + const computed = getBoolean(property, "computed") + + let key: PropertyKey + + if (computed) { + key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode) + } else if (keyNode.type === "Identifier") { + key = getString(keyNode, "name") + } else if (keyNode.type === "Literal") { + key = self.toPropertyKey(keyNode.value, keyNode) + } else { + throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode) + } + + if (isBlockedMember(String(key))) { + throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode) + } + objectValue[String(key)] = yield* self.evaluateExpression(valueNode) + } + + return objectValue + }) + } + + private evaluateArrayExpression(node: AstNode): Effect.Effect, unknown, R> { + const elements = getArray(node, "elements") + const values: Array = [] + + const self = this + return Effect.gen(function* () { + for (const elementValue of elements) { + if (elementValue === null) { + values.push(undefined) + continue + } + const element = asNode(elementValue, "elements") + if (element.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(element, "argument")) + const items = spreadItems(spread) + if (items === undefined) + throw new InterpreterRuntimeError( + "Array spread requires an array, string, Map, or Set in CodeMode.", + element, + ) + values.push(...items) + } else { + values.push(yield* self.evaluateExpression(element)) + } + } + return values + }) + } + + private evaluateTemplateLiteral(node: AstNode): Effect.Effect { + const quasis = getArray(node, "quasis") + const expressions = getArray(node, "expressions") + + let output = "" + + const self = this + return Effect.gen(function* () { + for (let index = 0; index < quasis.length; index += 1) { + const quasi = asNode(quasis[index], "quasis") + const rawValue = quasi.value + + if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") { + throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi) + } + + output += rawValue.cooked + + if (index < expressions.length) { + const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) + // The preserving checkpoint keeps sandbox values intact, so coerceToString renders + // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. + output += coerceToString(boundedData(raw, "Template interpolation")) + } + } + + return output + }) + } + + private evaluateConditionalExpression(node: AstNode): Effect.Effect { + return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) => + this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")), + ) + } + + private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown { + // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation + // so compound assignment inherits the same coercion semantics (Dates, data objects, ...). + // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=) + // short-circuit and are handled by evaluateLogicalAssignment before reaching here. + if (!compoundOperators.has(operator)) { + throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) + } + return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node) + } + + private getMemberReference( + node: AstNode, + ): Effect.Effect< + | MemberReference + | ToolReference + | PromiseMethodReference + | IntrinsicReference + | GlobalMethodReference + | ComputedValue + | typeof OptionalShortCircuit + | undefined, + unknown, + R + > { + const objectNode = getNode(node, "object") + const propertyNode = getNode(node, "property") + const computed = getBoolean(node, "computed") + const optional = node.optional === true + const self = this + return Effect.gen(function* () { + const objectValue = yield* self.evaluateExpression(objectNode) + if (objectValue === OptionalShortCircuit) return OptionalShortCircuit + if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit + + const key = computed + ? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + : propertyNode.type === "Identifier" + ? getString(propertyNode, "name") + : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + + if (objectValue instanceof ToolReference) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode) + } + return new ToolReference([...objectValue.path, key]) + } + + if (objectValue instanceof PromiseNamespace) { + if (typeof key === "string" && promiseStatics.has(key as PromiseMethodName)) { + return new PromiseMethodReference(key as PromiseMethodName) + } + throw new InterpreterRuntimeError( + `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`, + propertyNode, + ) + } + + if (objectValue instanceof GlobalNamespace) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError( + `${objectValue.name}.${String(key)} is not available in CodeMode.`, + propertyNode, + ) + } + if (objectValue.name === "Math" && mathConstants.has(key)) { + return new ComputedValue((Math as unknown as Record)[key]) + } + return new GlobalMethodReference(objectValue.name, key) + } + + if (typeof objectValue === "string") { + if (key === "length") return new ComputedValue(objectValue.length) + if (typeof key === "number") return new ComputedValue(objectValue[key]) + if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) + if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) + // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), + // instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string + // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a + // real string still reaches here.) Only the method allowlist above yields callables. + return new ComputedValue(undefined) + } + + if (typeof objectValue === "number") { + if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) + // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. + return new ComputedValue(undefined) + } + + // Number / String expose a small allowlist of statics; everything else stays opaque. + if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { + if (objectValue.name === "Number" && numberConstants.has(key)) { + return new ComputedValue((Number as unknown as Record)[key]) + } + if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key) + if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) + } + + // Sandbox value types expose their method/property allowlists; any other key reads as + // `undefined`, consistent with unknown-property reads on strings/numbers/arrays. + if (objectValue instanceof SandboxDate) { + if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxRegExp) { + if (typeof key === "string" && regexpProperties.has(key)) { + return new ComputedValue((objectValue.regex as unknown as Record)[key]) + } + if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxMap) { + if (key === "size") return new ComputedValue(objectValue.map.size) + if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxSet) { + if (key === "size") return new ComputedValue(objectValue.set.size) + if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxURL) { + if (key === "searchParams") { + return new ComputedValue(objectValue.searchParams) + } + if (typeof key === "string" && urlMethods.has(key)) return new IntrinsicReference(objectValue, key) + if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key } + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxURLSearchParams) { + if (key === "size") return new ComputedValue(objectValue.params.size) + if (typeof key === "string" && urlSearchParamsMethods.has(key)) { + return new IntrinsicReference(objectValue, key) + } + return new ComputedValue(undefined) + } + + // Any property access on a promise is a confused program (`p.then(...)`, `p.value`); + // reading `undefined` here would hide the missing await, so both paths get an explicit, + // await-hinting error instead of the forgiving unknown-property fallthrough. + if (objectValue instanceof SandboxPromise) { + if (key === "then" || key === "catch" || key === "finally") { + throw new InterpreterRuntimeError( + `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`, + propertyNode, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + throw new InterpreterRuntimeError( + "This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.", + objectNode, + "InvalidDataValue", + ) + } + + if (isRuntimeReference(objectValue)) { + throw new InterpreterRuntimeError( + "CodeMode runtime references are opaque and do not expose properties.", + objectNode, + "InvalidDataValue", + ) + } + + if (typeof objectValue !== "object" || objectValue === null) { + throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode) + } + + if (typeof key === "string" && isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode) + } + + if (Array.isArray(objectValue)) { + if ( + key !== "length" && + !(typeof key === "string" && arrayMethods.has(key)) && + typeof key !== "number" && + !/^\d+$/.test(key) + ) { + // Own non-index properties read through (match results carry index/groups); like JS, + // they are readable in place and dropped by JSON at data boundaries. + if (typeof key === "string" && Object.hasOwn(objectValue, key)) { + return new ComputedValue((objectValue as Record & Array)[key]) + } + // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), + // instead of throwing - so defensive access under optional chaining behaves as expected. + return new ComputedValue(undefined) + } + return { target: objectValue, key } + } + + return { target: objectValue as SafeObject, key } + }) + } + + private readMember(node: AstNode): Effect.Effect { + return Effect.map(this.getMemberReference(node), (reference) => { + if (reference === OptionalShortCircuit) return OptionalShortCircuit + if (reference instanceof ComputedValue) return reference.value + if ( + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) + return reference + if (Array.isArray(reference.target)) { + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + return new IntrinsicReference(reference.target, reference.key) + } + return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)] + } + if (reference.target instanceof SandboxURL) { + return (reference.target.url as unknown as Record)[String(reference.key)] + } + return reference.target[String(reference.key)] + }) + } + + private writeMember(node: AstNode, value: unknown): Effect.Effect { + return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) + } + + // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression + // runs once), then lets `compute` decide whether to write - enabling compound assignment, + // updates, plain writes, and short-circuiting logical assignment to share one safe path. + private modifyMember( + node: AstNode, + compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + const reference = yield* self.getMemberReference(node) + if ( + reference === OptionalShortCircuit || + reference instanceof ComputedValue || + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) { + throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node) + } + if (Array.isArray(reference.target)) { + if (reference.key === "length") + throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node) + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node) + } + } + const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key) + const current = + reference.target instanceof SandboxURL + ? (reference.target.url as unknown as Record)[key] + : (reference.target as Record)[key] + const { write, next, result } = yield* compute(current) + if (write) self.assignToReference(reference, key, next, node) + return result + }) + } + + // Rejects inserting a value that (transitively) contains the container it is being inserted + // into - the mutation that would create a circular structure no later walk could survive. + private rejectCircularInsertion( + container: object, + value: unknown, + label: string, + node: AstNode, + seen = new Set(), + ): void { + if (value === container) + throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return + seen.add(value) + const items = Array.isArray(value) ? value : Object.values(value) + for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) + seen.delete(value) + } + + private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { + if (Array.isArray(reference.target)) { + const target = reference.target + const index = key as number + if (!Number.isInteger(index) || index < 0) { + throw new InterpreterRuntimeError( + "Array assignment index must be a non-negative integer.", + node, + "InvalidDataValue", + ) + } + this.rejectCircularInsertion(target, next, "Array assignment result", node) + target[index] = next + return + } + if (reference.target instanceof SandboxURL) { + const property = key as string + if (!urlWritableProperties.has(property)) { + throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError") + } + try { + const url = reference.target.url as unknown as Record + url[property] = uriArgument(next, `URL.${property} value`) + return + } catch (error) { + if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error + throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError") + } + } + const target = reference.target as SafeObject + const objectKey = key as string + this.rejectCircularInsertion(target, next, "Object assignment result", node) + target[objectKey] = next + } + + private toPropertyKey(value: unknown, node: AstNode): string | number { + if (typeof value === "string" || typeof value === "number") { + return value + } + + throw new InterpreterRuntimeError("Property key must be a string or number.", node) + } + + private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { + const scope = this.currentScope() + + // A pre-seeded parameter slot (initialized === false) is being bound for the first time; + // anything else already present is a genuine duplicate declaration. + const existing = scope.get(name) + if (existing && existing.initialized !== false) { + throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) + } + + scope.set(name, { mutable, value, initialized: true }) + } + + private getIdentifierValue(name: string, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + // A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ. + if (binding.initialized === false) { + throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") + } + + return binding.value + } + + private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + if (!binding.mutable) { + throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") + } + + binding.value = value + return value + } + + private resolveBinding(name: string): Binding | undefined { + for (let index = this.scopes.length - 1; index >= 0; index -= 1) { + const scope = this.scopes[index] + const binding = scope?.get(name) + + if (binding) { + return binding + } + } + + return undefined + } + + private currentScope(): Map { + const scope = this.scopes[this.scopes.length - 1] + + if (!scope) { + throw new InterpreterRuntimeError("Interpreter scope stack is empty.") + } + + return scope + } + + private pushScope(): void { + this.scopes.push(new Map()) + } + + private popScope(): void { + this.scopes.pop() + } +} + +/** + * Executes one Effect-native CodeMode program without constructing a reusable runtime. + * + * @example + * ```ts + * const result = yield* CodeMode.execute({ + * tools: { lookup }, + * code: `return await tools.lookup({ id: "order_42" })`, + * }) + * ``` + */ +export const executeWithLimits = >( + options: ExecuteOptions, + limits: ResolvedExecutionLimits, + searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], +): Effect.Effect> => { + const hooks = { + ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), + ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), + } + const tools = ToolRuntime.make( + (options.tools ?? {}) as HostTools>, + limits.maxToolCalls, + searchIndex, + hooks, + ) + const logs: Array = [] + const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) + + if (options.code.trim().length === 0) { + return Effect.succeed({ + ok: false, + error: { kind: "ParseError", message: "Code cannot be empty." }, + toolCalls: tools.calls, + }) + } + + const operation = Effect.gen(function* () { + const program = parseProgram(options.code) + const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) + const value = yield* interpreter.run(program) + const result = copyOut(copyIn(value, "Execution result"), true) as DataValue + return { + ok: true, + value: result, + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }).pipe((program) => { + const timeoutMs = limits.timeoutMs + if (timeoutMs === undefined) return program + return program.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.succeed({ + ok: false, + error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, + ...logged(), + toolCalls: tools.calls, + } satisfies Result), + }), + ) + }) + + return operation.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.succeed({ + ok: false, + error: normalizeError(Cause.squash(cause)), + ...logged(), + toolCalls: tools.calls, + } satisfies Result), + ), + Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))), + ) +} + +const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength + +// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte +// sequence decodes to a replacement character, which is dropped). +const utf8Truncate = (value: string, maxBytes: number): string => { + const bytes = new TextEncoder().encode(value) + if (bytes.byteLength <= maxBytes) return value + const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) + return text.endsWith("\uFFFD") ? text.slice(0, -1) : text +} + +/** + * Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`. + * Oversized values are replaced by their truncated serialized text with an explanatory marker, + * and logs are kept from the start until the remaining budget is exhausted. Truncation never + * fails the execution; `truncated: true` marks affected results. Only runs when the host set + * `maxOutputBytes` - with the limit absent, output passes through unbounded. + */ +const boundOutput = (result: Result, maxOutputBytes: number): Result => { + let truncated = false + + let value: DataValue = null + let valueBytes = 0 + if (result.ok) { + const serialized = JSON.stringify(result.value) ?? "null" + const bytes = utf8ByteLength(serialized) + if (bytes > maxOutputBytes) { + truncated = true + value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` + valueBytes = maxOutputBytes + } else { + value = result.value + valueBytes = bytes + } + } + + const logs = result.logs ?? [] + const kept: Array = [] + const logBudget = Math.max(0, maxOutputBytes - valueBytes) + let logBytes = 0 + for (const line of logs) { + const lineBytes = utf8ByteLength(line) + 1 + if (logBytes + lineBytes > logBudget) break + logBytes += lineBytes + kept.push(line) + } + if (kept.length < logs.length) { + truncated = true + kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) + } + + if (!truncated) return result + const logsPart = kept.length > 0 ? { logs: kept } : {} + return result.ok + ? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls } + : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } +} diff --git a/packages/codemode/src/openapi/TODO.md b/packages/codemode/src/openapi/TODO.md new file mode 100644 index 0000000000000000000000000000000000000000..cbcfe81a68c8df2fa7aae622ddf2997e3ab29433 --- /dev/null +++ b/packages/codemode/src/openapi/TODO.md @@ -0,0 +1,19 @@ +# OpenAPI Follow-ups + +The initial adapter intentionally skips operations it cannot execute correctly. Future work may add: + +- Cookie parameters, authentication, and cookie-header merging. +- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization. +- External references and complete nested `$defs` support. +- Relative or templated server URLs and server variables. +- Base URLs containing query strings or fragments. +- Runtime response-schema validation and full content negotiation. +- Binary response values and explicit byte-oriented return types. +- Request/response projection for `readOnly` and `writeOnly` properties. +- SSE, WebSocket, and other streaming transports. +- Recovery of responses rejected by a status-filtering `HttpClient`. +- Configurable request and response size limits. +- Adapter-enforced redirect policy independent of the supplied `HttpClient`. +- Strict UTF-8 and empty-body validation for JSON responses. +- Compile-time rejection of parameter schemas with nested values unsupported by their serialization style; runtime rejects them before auth resolution. +- Complete malformed-security-scheme validation and broader auth-combination coverage. diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f1770ef362792996f6026dced24ab4b570added --- /dev/null +++ b/packages/codemode/src/openapi/index.ts @@ -0,0 +1,130 @@ +import { HttpClient } from "effect/unstable/http" +import { make, type Definition } from "../tool.js" +import { invoke } from "./runtime.js" +import { + componentDefinitions, + inputSchema, + isRecord, + methods, + nonEmptyString, + operationInput, + operationOutput, + operationPath, + operationSecurityRequirements, + securityRequirements, + securitySchemes, + specServerUrl, + validateBaseUrl, +} from "./spec.js" +import type { Operation, Options, Result, Skipped, Tools } from "./types.js" + +export type { + AuthResolver, + Credential, + Document, + Operation, + Options, + Result, + SecurityScheme, + Skipped, + Tools, +} from "./types.js" + +/** + * Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per + * operation. Auth is resolved host-side via `auth.resolve` and never + * model-visible. Tools require `HttpClient.HttpClient`; unrepresentable + * operations land in `skipped`. + */ +export const fromSpec = (options: Options): Result => { + const document = options.spec + const schemes = securitySchemes(document) + const defaultSecurity = securityRequirements(document.security) + const definitions = componentDefinitions(document) + const paths = isRecord(document.paths) ? document.paths : {} + const used = new Set() + const namespaces = new Set() + const skipped: Array = [] + const tools = Object.create(null) as Tools + + for (const [path, pathValue] of Object.entries(paths)) { + if (!isRecord(pathValue)) continue + for (const [method, operationValue] of Object.entries(pathValue)) { + if (!methods.has(method) || !isRecord(operationValue)) continue + const segments = operationPath(method, path, operationValue, used, namespaces) + const operation: Operation = { + operationId: nonEmptyString(operationValue.operationId), + method: method.toUpperCase(), + path, + summary: nonEmptyString(operationValue.summary), + description: nonEmptyString(operationValue.description), + } + const output = operationOutput(document, operationValue, definitions) + if (!output.ok) { + skipped.push({ method: operation.method, path, reason: output.reason }) + continue + } + + const resolvedBaseUrl = (() => { + if (options.baseUrl !== undefined) return validateBaseUrl(options.baseUrl) + if (operationValue.servers !== undefined) return specServerUrl(operationValue) + if (pathValue.servers !== undefined) return specServerUrl(pathValue) + return specServerUrl(document) + })() + if (!resolvedBaseUrl.ok) { + skipped.push({ method: operation.method, path, reason: resolvedBaseUrl.reason }) + continue + } + const parsedInput = operationInput(document, pathValue, operationValue) + if (!parsedInput.ok) { + skipped.push({ method: operation.method, path, reason: parsedInput.reason }) + continue + } + const input = parsedInput.value + + const security = operationSecurityRequirements(operationValue.security, defaultSecurity, schemes) + if (!security.ok) { + skipped.push({ method: operation.method, path, reason: security.reason }) + continue + } + const plan = { + operation, + url: `${resolvedBaseUrl.value.replace(/\/+$/, "")}${path}`, + fields: input.fields, + body: input.body, + security: security.value, + schemes, + auth: options.auth, + headers: options.headers ?? {}, + } + used.add(segments.join(".")) + for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join(".")) + setTool( + tools, + segments, + make({ + description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, + input: inputSchema(input.fields, definitions), + output: output.value, + run: (input) => invoke(plan, input), + }), + ) + } + } + + return { tools, skipped } +} + +const setTool = (tools: Tools, path: ReadonlyArray, definition: Definition): void => { + const [head, ...rest] = path + if (head === undefined) return + if (rest.length === 0) { + tools[head] = definition + return + } + const child = tools[head] + if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") { + tools[head] = Object.create(null) as Tools + } + setTool(tools[head] as Tools, rest, definition) +} diff --git a/packages/codemode/src/openapi/runtime.ts b/packages/codemode/src/openapi/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..2515621792b22f23fa52967362ecf69828e21ee4 --- /dev/null +++ b/packages/codemode/src/openapi/runtime.ts @@ -0,0 +1,326 @@ +import { Effect, Option, Schema, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse, type HttpMethod } from "effect/unstable/http" +import { ToolError, toolError } from "../tool-error.js" +import { isRecord, own } from "./spec.js" +import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js" + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const maxErrorBodyChars = 1_024 +const maxResponseBodyBytes = 50 * 1024 * 1024 + +export const invoke = (plan: Plan, input: unknown): Effect.Effect => + Effect.gen(function* () { + const value = isRecord(input) ? input : {} + + let request = yield* buildRequest(plan, value) + + const auth = yield* resolveAuth(plan) + for (const [name, item] of Object.entries(auth.query)) { + request = HttpClientRequest.setUrlParam(request, name, item) + } + request = HttpClientRequest.setHeaders(request, auth.headers) + + const client = yield* HttpClient.HttpClient + const response = yield* client + .execute(request) + .pipe( + Effect.catch((cause) => + Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)), + ), + ) + const text = yield* readResponseBody(response, plan) + const mediaType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase() + const json = mediaType === "application/json" || mediaType?.endsWith("+json") === true + const decoded = text === "" ? Option.some(null) : json ? decodeJson(text) : Option.none() + const parsed = json ? Option.getOrElse(decoded, () => text) : text === "" ? null : text + if (response.status < 200 || response.status >= 300) { + const rendered = typeof parsed === "string" ? parsed : (JSON.stringify(parsed) ?? "") + const summary = + rendered === "" || rendered === "null" + ? "no response body" + : rendered.length > maxErrorBodyChars + ? `${rendered.slice(0, maxErrorBodyChars)}...` + : rendered + return yield* Effect.fail( + toolError(`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summary}`), + ) + } + if (json && Option.isNone(decoded)) { + return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`)) + } + return parsed + }) + +const buildRequest = ( + plan: Plan, + input: Readonly>, +): Effect.Effect => + Effect.gen(function* () { + // Validate every model-controlled value before auth resolution, which may refresh tokens. + const url = buildUrl(plan, input) + if (url instanceof ToolError) return yield* Effect.fail(url) + const missing = plan.fields.find( + (field) => field.required && field.location !== "path" && own(input, field.inputName) === undefined, + ) + if (missing !== undefined) { + const label = missing.location === "body" ? "body field" : `${missing.location} parameter` + return yield* Effect.fail(toolError(`Missing required ${label} '${missing.inputName}'.`)) + } + + let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url) + for (const field of plan.fields) { + if (field.location !== "query") continue + const item = own(input, field.inputName) + if (item === undefined) continue + const serialized = serializeQuery(request, field, item) + if (serialized instanceof ToolError) return yield* Effect.fail(serialized) + request = serialized + } + + // Host headers first, then declared header parameters. + request = HttpClientRequest.setHeaders(request, plan.headers) + for (const field of plan.fields) { + if (field.location !== "header") continue + const item = own(input, field.inputName) + if (item === undefined) continue + const serialized = serializeSimple(field, item, String) + if (serialized instanceof ToolError) return yield* Effect.fail(serialized) + request = HttpClientRequest.setHeader(request, field.name, serialized) + } + + const setBody = (value: unknown, mediaType: string) => + HttpClientRequest.bodyJson(request, value).pipe( + Effect.map((next) => HttpClientRequest.setHeader(next, "content-type", mediaType)), + Effect.mapError((cause) => + toolError(`Invalid JSON body for ${plan.operation.method} ${plan.operation.path}.`, cause), + ), + ) + if (plan.body?.mode === "value") { + const field = plan.fields.find((field) => field.location === "body") + const body = field === undefined ? undefined : own(input, field.inputName) + if (body !== undefined) request = yield* setBody(body, plan.body.mediaType) + } + if (plan.body?.mode === "object") { + const entries = plan.fields.flatMap((field) => { + if (field.location !== "body") return [] + const item = own(input, field.inputName) + return item === undefined ? [] : [[field.name, item] as const] + }) + if (plan.body.required || entries.length > 0) { + request = yield* setBody(Object.fromEntries(entries), plan.body.mediaType) + } + } + return request + }) + +const resolveAuth = (plan: Plan): Effect.Effect => + Effect.gen(function* () { + const none: AppliedAuth = { headers: {}, query: {} } + if (plan.security.length === 0) return none + + const unavailable: Array = [] + alternatives: for (const requirement of plan.security) { + const names = Object.keys(requirement) + if (names.length === 0) return none + const credentials: Array = [] + for (const name of names) { + const scheme = own(plan.schemes, name) + if (scheme === undefined || plan.auth === undefined) { + unavailable.push(name) + continue alternatives + } + const credential = yield* plan.auth.resolve({ + name, + definition: scheme, + scopes: requirement[name] ?? [], + operation: plan.operation, + }) + if (credential === undefined) { + unavailable.push(name) + continue alternatives + } + credentials.push([name, scheme, credential]) + } + const applied = applyCredentials(credentials) + return applied instanceof ToolError ? yield* Effect.fail(applied) : applied + } + + return yield* Effect.fail( + toolError( + `${plan.operation.method} ${plan.operation.path} requires authentication; no credential available for: ${[...new Set(unavailable)].join(", ")}.`, + ), + ) + }) + +const applyCredentials = ( + credentials: ReadonlyArray, +): AppliedAuth | ToolError => { + const headers = new Map() + const query = new Map() + const add = (carrier: "header" | "query", name: string, value: string): ToolError | undefined => { + const target = carrier === "header" ? headers : query + if (target.has(name)) return toolError(`Authentication resolves multiple credentials for ${carrier} '${name}'.`) + target.set(name, value) + } + for (const [name, definition, credential] of credentials) { + if (credential.type === "bearer") { + const duplicate = add("header", "authorization", `Bearer ${credential.token}`) + if (duplicate !== undefined) return duplicate + continue + } + if (credential.type === "basic") { + // Buffer instead of btoa: btoa throws on non-Latin-1 credentials. + const duplicate = add( + "header", + "authorization", + `Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}`, + ) + if (duplicate !== undefined) return duplicate + continue + } + if (credential.type === "header") { + const duplicate = add("header", credential.name.toLowerCase(), credential.value) + if (duplicate !== undefined) return duplicate + continue + } + // apiKey: the carrier comes from the scheme declaration. + if (definition.type !== "apiKey") { + return toolError( + `Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`, + ) + } + if (definition.in === "cookie") return toolError(`Cookie authentication '${name}' is not supported.`) + const parameter = definition.in === "header" ? definition.name.toLowerCase() : definition.name + const duplicate = add(definition.in, parameter, credential.value) + if (duplicate !== undefined) return duplicate + } + return { headers: Object.fromEntries(headers), query: Object.fromEntries(query) } +} + +const buildUrl = (plan: Plan, input: Readonly>): string | ToolError => { + let url = plan.url + for (const field of plan.fields) { + if (field.location !== "path") continue + const item = own(input, field.inputName) + if (item === undefined) { + return toolError(`Missing required path parameter '${field.inputName}'.`) + } + const fieldValue = serializeSimple(field, item, (value) => + encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ), + ) + if (fieldValue instanceof ToolError) return fieldValue + // '.'/'..' survive encoding and URL normalization collapses them, letting a + // model-supplied value retarget the request to a different endpoint. + if (fieldValue === "" || fieldValue === "." || fieldValue === "..") { + return toolError(`Invalid path parameter '${field.inputName}'.`) + } + url = url.replaceAll(`{${field.name}}`, fieldValue) + } + const unresolved = url.match(/\{[^{}]+\}/) + if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`) + return url +} + +const serializeSimple = ( + field: Plan["fields"][number], + value: unknown, + encode: (value: string) => string, +): string | ToolError => { + const scalar = (item: unknown): string | ToolError => + item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean" + ? toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`) + : encode(String(item)) + if (Array.isArray(value)) { + const items = value.map(scalar) + const invalid = items.find((item): item is ToolError => item instanceof ToolError) + return invalid ?? items.join(",") + } + if (!isRecord(value)) return scalar(value) + const entries = Object.entries(value).flatMap(([name, item]) => { + const rendered = scalar(item) + if (rendered instanceof ToolError) return [rendered] + return field.explode ? [`${encode(name)}=${rendered}`] : [encode(name), rendered] + }) + const invalid = entries.find((item): item is ToolError => item instanceof ToolError) + return invalid ?? entries.join(",") +} + +const serializeQuery = ( + request: HttpClientRequest.HttpClientRequest, + field: Plan["fields"][number], + value: unknown, +): HttpClientRequest.HttpClientRequest | ToolError => { + if (field.style === "deepObject") { + if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`) + return Object.entries(value).reduce((current, [name, item]) => { + if (current instanceof ToolError) return current + if (item === undefined || (item !== null && typeof item === "object")) { + return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`) + } + return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item)) + }, request) + } + if (Array.isArray(value)) { + const rendered = serializeSimple(field, value, String) + if (rendered instanceof ToolError) return rendered + if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered) + if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) { + return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`) + } + return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request) + } + if (isRecord(value) && field.explode) { + return Object.entries(value).reduce((current, [name, item]) => { + if (current instanceof ToolError) return current + if (item === undefined || (item !== null && typeof item === "object")) { + return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`) + } + return HttpClientRequest.appendUrlParam(current, name, String(item)) + }, request) + } + const rendered = serializeSimple(field, value, String) + return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered) +} + +const readResponseBody = ( + response: HttpClientResponse.HttpClientResponse, + plan: Plan, +): Effect.Effect => + Effect.gen(function* () { + const contentLength = response.headers["content-length"] + const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10) + const declaredSize = + parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined + if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) { + return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`)) + } + let body = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, declaredSize ?? 64 * 1024)) + let size = 0 + yield* Stream.runForEach(response.stream, (chunk) => { + if (size + chunk.byteLength > maxResponseBodyBytes) { + return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`)) + } + if (size + chunk.byteLength > body.byteLength) { + const grown = Buffer.allocUnsafe( + Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)), + ) + body.copy(grown, 0, 0, size) + body = grown + } + body.set(chunk, size) + size += chunk.byteLength + return Effect.void + }).pipe( + Effect.catch((cause) => { + if (cause instanceof ToolError) return Effect.fail(cause) + if (cause.reason._tag === "EmptyBodyError") return Effect.void + return Effect.fail( + toolError(`${plan.operation.method} ${plan.operation.path} failed while reading the response body.`, cause), + ) + }), + ) + return new TextDecoder().decode(body.subarray(0, size)) + }) diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd4dc5aed63e44d81e4976a7e2e374cc44c53d6d --- /dev/null +++ b/packages/codemode/src/openapi/spec.ts @@ -0,0 +1,511 @@ +import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema" +import type { JsonSchema } from "../tool.js" +import { isBlockedMember } from "../tool-runtime.js" +import type { + Body, + Document, + InputField, + OperationInput, + Parsed, + SecurityRequirement, + SecurityScheme, +} from "./types.js" + +export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]) +const parameterLocations = ["path", "query", "header"] as const +const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"]) + +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const asArray = (value: unknown): ReadonlyArray => (Array.isArray(value) ? value : []) + +export const nonEmptyString = (value: unknown): string | undefined => + typeof value === "string" && value !== "" ? value : undefined + +// Guards record lookups keyed by spec- or model-controlled names against +// prototype-inherited values (e.g. a parameter named `toString`). +export const own = (record: Readonly>, key: string): T | undefined => + Object.hasOwn(record, key) ? record[key] : undefined + +export const resolve = (document: Document, value: unknown): unknown => { + const next = (current: unknown, seen: ReadonlySet): unknown => { + if (!isRecord(current)) return current + const ref = nonEmptyString(current.$ref) + if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current + const target = ref + .slice(2) + .split("/") + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document) + return target === undefined ? current : next(target, new Set([...seen, ref])) + } + return next(value, new Set()) +} + +const projectSchema = (document: Document, value: unknown): JsonSchema => { + if (!isRecord(value)) return {} + const normalized = nonEmptyString(document.openapi)?.startsWith("3.0") + ? fromSchemaOpenApi3_0(value) + : fromSchemaOpenApi3_1(value) + return Object.keys(normalized.definitions).length === 0 + ? normalized.schema + : { ...normalized.schema, $defs: normalized.definitions } +} + +export const componentDefinitions = (document: Document): Readonly> => { + const components = isRecord(document.components) ? document.components : {} + const schemas = isRecord(components.schemas) ? components.schemas : {} + return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)])) +} + +const withDefinitions = (schema: JsonSchema, definitions: Readonly>): JsonSchema => { + if (Object.keys(definitions).length === 0) return schema + const local = isRecord(schema.$defs) ? schema.$defs : {} + return { ...schema, $defs: { ...definitions, ...local } } +} + +const isJsonMediaType = (mediaType: string): boolean => { + const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? "" + return normalized === "application/json" || normalized.endsWith("+json") +} + +const isBinaryMediaType = (document: Document, mediaType: string, value: unknown): boolean => { + const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? "" + if (!isJsonMediaType(normalized) && !normalized.startsWith("text/")) return true + if (!isRecord(value)) return false + const schema = resolve(document, value.schema) + return isRecord(schema) && schema.format === "binary" +} + +const jsonContent = ( + content: Record, +): { readonly mediaType: string; readonly schema: unknown } | undefined => { + const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType)) + return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined +} + +const isFlattenableObjectBody = ( + schema: unknown, + requestRequired: boolean, +): schema is Record & { readonly properties: Record } => + isRecord(schema) && + requestRequired && + schema.type === "object" && + isRecord(schema.properties) && + schema.additionalProperties === false && + schema.nullable !== true && + schema.allOf === undefined && + schema.anyOf === undefined && + schema.oneOf === undefined + +type PlannedField = Omit + +const operationParameters = ( + document: Document, + pathItem: Record, + operation: Record, +): Parsed> => { + // Operation-level parameters override path-level ones sharing (location, name). + const declared = new Map< + string, + { readonly name: string; readonly location: string; readonly parameter: Record } + >() + for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) { + const resolved = resolve(document, raw) + if (!isRecord(resolved)) return { ok: false, reason: "parameter declaration is invalid or unresolved" } + const name = nonEmptyString(resolved.name) + const location = nonEmptyString(resolved.in) + if (name === undefined || location === undefined) + return { ok: false, reason: "parameter declaration is missing name or location" } + declared.set(`${location}:${name}`, { name, location, parameter: resolved }) + } + const unordered: Array = [] + for (const item of declared.values()) { + const name = item.name + const location = item.location + const resolved = item.parameter + if (location === "cookie") return { ok: false, reason: `cookie parameter '${name}' is not supported` } + if (location !== "path" && location !== "query" && location !== "header") { + return { ok: false, reason: `parameter '${name}' uses unsupported location '${location}'` } + } + if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue + if (resolved.schema === undefined && resolved.content === undefined) { + return { ok: false, reason: `parameter '${name}' declares neither schema nor content` } + } + if (resolved.content !== undefined) + return { ok: false, reason: `parameter '${name}' uses unsupported content encoding` } + if (resolved.style !== undefined && nonEmptyString(resolved.style) === undefined) { + return { ok: false, reason: `parameter '${name}' has an invalid style` } + } + if (resolved.explode !== undefined && typeof resolved.explode !== "boolean") { + return { ok: false, reason: `parameter '${name}' has an invalid explode value` } + } + if (resolved.allowReserved !== undefined && typeof resolved.allowReserved !== "boolean") { + return { ok: false, reason: `parameter '${name}' has an invalid allowReserved value` } + } + if (resolved.allowReserved === true) + return { ok: false, reason: `parameter '${name}' uses unsupported allowReserved encoding` } + const declaredStyle = nonEmptyString(resolved.style) ?? (location === "query" ? "form" : "simple") + if (location === "query" && declaredStyle !== "form" && declaredStyle !== "deepObject") { + return { ok: false, reason: `query parameter '${name}' uses unsupported style '${declaredStyle}'` } + } + if (location !== "query" && declaredStyle !== "simple") { + return { ok: false, reason: `${location} parameter '${name}' uses unsupported style '${declaredStyle}'` } + } + const style = declaredStyle === "deepObject" ? "deepObject" : declaredStyle === "form" ? "form" : "simple" + const explode = typeof resolved.explode === "boolean" ? resolved.explode : style === "form" + if (style === "deepObject" && !explode) { + return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` } + } + const base = projectSchema(document, resolved.schema) + const description = nonEmptyString(resolved.description) + unordered.push({ + name, + location, + required: resolved.required === true || location === "path", + style, + explode, + schema: { + ...base, + ...(base.description === undefined && description !== undefined ? { description } : {}), + }, + }) + } + return { + ok: true, + value: parameterLocations.flatMap((location) => unordered.filter((field) => field.location === location)), + } +} + +const operationBody = ( + document: Document, + operation: Record, +): Parsed<{ readonly fields: ReadonlyArray; readonly body: Body | undefined }> => { + const resolved = resolve(document, operation.requestBody) + if (!isRecord(resolved)) return { ok: true, value: { fields: [], body: undefined } } + const content = isRecord(resolved.content) ? resolved.content : {} + const selected = jsonContent(content) + if (selected === undefined) { + return { + ok: false, + reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`, + } + } + const schema = resolve(document, selected.schema) + const required = resolved.required === true + if (!isFlattenableObjectBody(schema, required)) { + return { + ok: true, + value: { + fields: [ + { + name: "body", + location: "body", + required, + schema: projectSchema(document, selected.schema), + style: undefined, + explode: undefined, + }, + ], + body: { required, mode: "value", mediaType: selected.mediaType }, + }, + } + } + const requiredProperties = new Set( + Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === "string") : [], + ) + return { + ok: true, + value: { + fields: Object.entries(schema.properties).map(([name, value]) => ({ + name, + location: "body" as const, + required: required && requiredProperties.has(name), + schema: projectSchema(document, value), + style: undefined, + explode: undefined, + })), + body: { required, mode: "object", mediaType: selected.mediaType }, + }, + } +} + +export const operationInput = ( + document: Document, + pathItem: Record, + operation: Record, +): Parsed => { + const parameters = operationParameters(document, pathItem, operation) + if (!parameters.ok) return parameters + const requestBody = operationBody(document, operation) + if (!requestBody.ok) return requestBody + const fields = [...parameters.value, ...requestBody.value.fields] + + const conflicts = new Set( + [...Map.groupBy(fields, (field) => field.name)] + .filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1) + .map(([name]) => name), + ) + const used = new Set() + return { + ok: true, + value: { + fields: fields.map((field) => { + const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name + const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName + const next = (index: number): string => { + const candidate = index === 1 ? base : `${base}_${index}` + return used.has(candidate) ? next(index + 1) : candidate + } + const inputName = next(1) + used.add(inputName) + return { ...field, inputName } + }), + body: requestBody.value.body, + }, + } +} + +export const inputSchema = ( + fields: ReadonlyArray, + definitions: Readonly>, +): JsonSchema => { + const required = fields.filter((field) => field.required).map((field) => field.inputName) + return withDefinitions( + { + type: "object", + properties: Object.fromEntries(fields.map((field) => [field.inputName, field.schema])), + ...(required.length === 0 ? {} : { required }), + }, + definitions, + ) +} + +const successfulResponses = ( + document: Document, + operation: Record, +): Parsed>> => { + if (!isRecord(operation.responses)) return { ok: true, value: [] } + const entries = Object.entries(operation.responses) + const selected = [ + ...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)), + ...entries.filter(([status]) => status.toUpperCase() === "2XX"), + ] + const responses: Array> = [] + for (const [, value] of selected) { + const resolved = resolve(document, value) + if (!isRecord(resolved) || nonEmptyString(resolved.$ref) !== undefined) { + return { ok: false, reason: "successful response declaration is invalid or unresolved" } + } + responses.push(resolved) + } + return { ok: true, value: responses } +} + +export const operationOutput = ( + document: Document, + operation: Record, + definitions: Readonly>, +): Parsed => { + if (operation["x-websocket"] === true) return { ok: false, reason: "WebSocket operations are not supported" } + const responses = successfulResponses(document, operation) + if (!responses.ok) return responses + const streams = responses.value.some( + (response) => + isRecord(response.content) && + Object.keys(response.content).some( + (mediaType) => mediaType.split(";")[0]?.trim().toLowerCase() === "text/event-stream", + ), + ) + if (streams) return { ok: false, reason: "SSE operations are not supported" } + const binary = responses.value.some( + (response) => + isRecord(response.content) && + Object.entries(response.content).some(([mediaType, value]) => isBinaryMediaType(document, mediaType, value)), + ) + if (binary) return { ok: false, reason: "binary responses are not supported" } + + const outcomes: Array = [] + for (const response of responses.value) { + if (response.content !== undefined && !isRecord(response.content)) return { ok: true, value: undefined } + const content = isRecord(response.content) ? response.content : {} + if (Object.keys(content).length === 0) { + outcomes.push({ type: "null" }) + continue + } + for (const [mediaType, value] of Object.entries(content)) { + if (!isJsonMediaType(mediaType)) { + outcomes.push({ type: "string" }) + continue + } + if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined } + outcomes.push(projectSchema(document, value.schema)) + } + } + if (outcomes.length === 0) return { ok: true, value: undefined } + return { + ok: true, + value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions), + } +} + +const sanitizeOperationSegment = (raw: string): string => { + const base = + raw + .replaceAll(/[^A-Za-z0-9_$]+/g, "_") + .replace(/^_+|_+$/g, "") + .replace(/^([0-9])/, "_$1") || "operation" + return isBlockedMember(base) ? `${base}_2` : base +} + +const fallbackOperationId = (method: string, path: string): string => + [ + method, + ...path + .split("/") + .filter((part) => part !== "") + .flatMap((part) => (part.startsWith("{") && part.endsWith("}") ? ["by", part.slice(1, -1)] : [part])) + .flatMap((part) => part.split(/[^A-Za-z0-9]+/).filter((word) => word !== "")), + ] + .map((word, index) => { + const lower = word.toLowerCase() + return index === 0 ? lower : `${lower.charAt(0).toUpperCase()}${lower.slice(1)}` + }) + .join("") + +export const operationPath = ( + method: string, + path: string, + operation: Record, + used: ReadonlySet, + namespaces: ReadonlySet, +): ReadonlyArray => { + const raw = nonEmptyString(operation.operationId) + const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map( + sanitizeOperationSegment, + ) + if (isOperationPathAvailable(segments, used, namespaces)) return segments + const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join("."))) + if (conflict >= 0 && conflict + 1 < segments.length) { + const collapsed = segments.flatMap((segment, index) => { + if (index === conflict) { + const next = segments[index + 1] ?? "" + return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`] + } + return index === conflict + 1 ? [] : [segment] + }) + if (isOperationPathAvailable(collapsed, used, namespaces)) return collapsed + } + const fallback = segments.join("_") + const next = (index: number): string => { + const candidate = `${fallback}_${index}` + return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1) + } + return [next(2)] +} + +const isOperationPathAvailable = ( + segments: ReadonlyArray, + used: ReadonlySet, + namespaces: ReadonlySet, +): boolean => { + const key = segments.join(".") + if (used.has(key) || namespaces.has(key)) return false + return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join("."))) +} + +export const specServerUrl = (source: Record): Parsed => { + const server = asArray(source.servers).find(isRecord) + const url = server === undefined ? undefined : nonEmptyString(server.url) + if (url === undefined) return { ok: false, reason: "spec declares no servers; pass baseUrl" } + if (/\{[^{}]+\}/.test(url)) { + return { ok: false, reason: `server URL '${url}' is not an absolute URL; pass baseUrl` } + } + return validateBaseUrl(url) +} + +export const validateBaseUrl = (value: string): Parsed => { + if (!/^https?:\/\//i.test(value)) return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` } + const url = URL.parse(value) + if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) { + return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` } + } + if (url.search !== "" || url.hash !== "") { + return { ok: false, reason: `server URL '${value}' contains an unsupported query string or fragment` } + } + return { ok: true, value } +} + +export const securityRequirements = (value: unknown): Parsed> => { + if (value === undefined) return { ok: true, value: [] } + if (!Array.isArray(value)) return { ok: false, reason: "security declaration is not an array" } + const requirements: Array = [] + for (const item of value) { + if (!isRecord(item)) return { ok: false, reason: "security requirement is not an object" } + const requirement = Object.create(null) as Record> + for (const [name, scopes] of Object.entries(item)) { + if (!Array.isArray(scopes)) return { ok: false, reason: "security requirement scopes are not string arrays" } + const parsed = scopes.filter((scope): scope is string => typeof scope === "string") + if (parsed.length !== scopes.length) { + return { ok: false, reason: "security requirement scopes are not string arrays" } + } + requirement[name] = parsed + } + requirements.push(requirement) + } + return { ok: true, value: requirements } +} + +export const operationSecurityRequirements = ( + value: unknown, + defaults: Parsed>, + schemes: Readonly>, +): Parsed> => { + const parsed = value === undefined ? defaults : securityRequirements(value) + if (!parsed.ok) return parsed + const supported = parsed.value.filter((requirement) => + Object.keys(requirement).every((name) => { + const scheme = own(schemes, name) + return scheme !== undefined && !(scheme.type === "apiKey" && scheme.in === "cookie") + }), + ) + if (parsed.value.length === 0 || supported.length > 0) return { ok: true, value: supported } + + const names = [...new Set(parsed.value.flatMap((requirement) => Object.keys(requirement)))] + const cookieScheme = names.find((name) => { + const definition = own(schemes, name) + return definition?.type === "apiKey" && definition.in === "cookie" + }) + return { + ok: false, + reason: + cookieScheme === undefined + ? `security requirement references missing or malformed scheme: ${names.join(", ")}` + : `cookie authentication '${cookieScheme}' is not supported`, + } +} + +export const securitySchemes = (document: Document): Readonly> => { + const components = isRecord(document.components) ? document.components : {} + const declared = isRecord(components.securitySchemes) ? components.securitySchemes : {} + return Object.fromEntries( + Object.entries(declared).flatMap(([name, value]) => { + const resolved = resolve(document, value) + if (!isRecord(resolved)) return [] + const type = nonEmptyString(resolved.type) + if (type === "apiKey") { + const carrier = nonEmptyString(resolved.in) + const parameter = nonEmptyString(resolved.name) + if (parameter === undefined || (carrier !== "header" && carrier !== "query" && carrier !== "cookie")) return [] + return [[name, { type, name: parameter, in: carrier }] as const] + } + if (type === "http") { + const scheme = nonEmptyString(resolved.scheme)?.toLowerCase() + return scheme === undefined ? [] : [[name, { type, scheme }] as const] + } + if (type === "oauth2" || type === "openIdConnect") return [[name, { type }] as const] + return [] + }), + ) +} diff --git a/packages/codemode/src/openapi/types.ts b/packages/codemode/src/openapi/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..cab772e701486b9a1af29dfe0798ce8bf77124ef --- /dev/null +++ b/packages/codemode/src/openapi/types.ts @@ -0,0 +1,112 @@ +import { Effect } from "effect" +import { HttpClient } from "effect/unstable/http" +import type { Definition, JsonSchema } from "../tool.js" + +/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */ +export type Document = Record + +/** The operation identity handed to auth resolution and errors. */ +export type Operation = { + readonly operationId: string | undefined + readonly method: string + readonly path: string + readonly summary: string | undefined + readonly description: string | undefined +} + +/** A resolved OpenAPI security scheme from `components.securitySchemes`. */ +export type SecurityScheme = + | { readonly type: "apiKey"; readonly name: string; readonly in: "header" | "query" | "cookie" } + | { readonly type: "http"; readonly scheme: string } + | { readonly type: "oauth2" } + | { readonly type: "openIdConnect" } + +/** + * Credential material returned by a host auth resolver. The carrier for `apiKey` + * comes from the scheme definition, not the credential. `header` is the escape + * hatch for nonstandard schemes. + */ +export type Credential = + | { readonly type: "bearer"; readonly token: string } + | { readonly type: "basic"; readonly username: string; readonly password: string } + | { readonly type: "apiKey"; readonly value: string } + | { readonly type: "header"; readonly name: string; readonly value: string } + +/** + * Resolves credential material for one named security scheme at call time. + * `undefined` means unavailable, try the next OR alternative; a failure aborts + * the call rather than falling through. + */ +export type AuthResolver = (context: { + readonly name: string + readonly definition: SecurityScheme + readonly scopes: ReadonlyArray + readonly operation: Operation +}) => Effect.Effect + +export type Options = { + readonly spec: Document + /** Overrides all document, path, and operation `servers`. Required when no applicable absolute server URL exists. */ + readonly baseUrl?: string | undefined + /** Host credential resolution, keyed by security scheme name. */ + readonly auth?: { readonly resolve: AuthResolver } | undefined + /** Static headers on every request. Not model-visible; declared header params may override them, auth always wins. */ + readonly headers?: Readonly> | undefined +} + +/** An operation that could not be represented as a tool, and why. */ +export type Skipped = { + readonly method: string + readonly path: string + readonly reason: string +} + +export type Tools = { [name: string]: Definition | Tools } + +export type Result = { + /** Tool subtree; the host places it under a key in its `tools` tree. */ + readonly tools: Tools + readonly skipped: ReadonlyArray +} + +export type Parsed = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string } + +export type InputLocation = "path" | "query" | "header" | "body" + +export type InputField = { + /** Model-visible field name after cross-location collision handling. */ + readonly inputName: string + /** Original parameter or body-property name used on the wire. */ + readonly name: string + readonly location: InputLocation + readonly required: boolean + readonly schema: JsonSchema + readonly style: "simple" | "form" | "deepObject" | undefined + readonly explode: boolean | undefined +} + +export type Body = { readonly required: boolean; readonly mode: "object" | "value"; readonly mediaType: string } + +export type OperationInput = { + readonly fields: ReadonlyArray + readonly body: Body | undefined +} + +/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */ +export type SecurityRequirement = Readonly>> + +export type Plan = { + readonly operation: Operation + readonly url: string + readonly fields: ReadonlyArray + readonly body: Body | undefined + readonly security: ReadonlyArray + readonly schemes: Readonly> + readonly auth: { readonly resolve: AuthResolver } | undefined + readonly headers: Readonly> +} + +export type AppliedAuth = { + readonly headers: Readonly> + readonly query: Readonly> +} diff --git a/packages/codemode/src/stdlib/collections.ts b/packages/codemode/src/stdlib/collections.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4760ff7061a7b93f495323abbf9b14027431b60 --- /dev/null +++ b/packages/codemode/src/stdlib/collections.ts @@ -0,0 +1,51 @@ +export const arrayMethods = new Set([ + "map", + "filter", + "find", + "findIndex", + "findLast", + "findLastIndex", + "some", + "every", + "includes", + "join", + "reduce", + "reduceRight", + "flatMap", + "forEach", + "sort", + "toSorted", + "slice", + "concat", + "indexOf", + "lastIndexOf", + "at", + "flat", + "reverse", + "toReversed", + "with", + "push", + "pop", + "shift", + "unshift", + "splice", + "fill", + "copyWithin", + "keys", + "values", + "entries", +]) + +export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) + +export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) + +export const spreadItems = (value: unknown): Array | undefined => { + if (Array.isArray(value)) return value + if (typeof value === "string") return Array.from(value) + if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item]) + if (value instanceof SandboxSet) return Array.from(value.set.values()) + if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item]) + return undefined +} +import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js" diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts new file mode 100644 index 0000000000000000000000000000000000000000..798563128e560a501f44494c53a99f7e47b72696 --- /dev/null +++ b/packages/codemode/src/stdlib/console.ts @@ -0,0 +1,4 @@ +export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"]) + +/** Console formatting recursion ceiling; deeper values render as "...". */ +export const MAX_CONSOLE_DEPTH = 32 diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts new file mode 100644 index 0000000000000000000000000000000000000000..c492f58f94b1e7246b8d098b309c047024861dc2 --- /dev/null +++ b/packages/codemode/src/stdlib/date.ts @@ -0,0 +1,94 @@ +export const dateMethods = new Set([ + "getTime", + "valueOf", + "toISOString", + "toJSON", + "toString", + "getFullYear", + "getMonth", + "getDate", + "getDay", + "getHours", + "getMinutes", + "getSeconds", + "getMilliseconds", + "getUTCFullYear", + "getUTCMonth", + "getUTCDate", + "getUTCDay", + "getUTCHours", + "getUTCMinutes", + "getUTCSeconds", + "getUTCMilliseconds", + "getTimezoneOffset", +]) + +export const dateStatics = new Set(["now", "parse", "UTC"]) + +export const invokeDateStatic = (name: string, args: Array, node: AstNode): number => { + switch (name) { + case "now": + return Date.now() + case "parse": + return Date.parse(coerceToString(args[0])) + case "UTC": + return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters)) + default: + throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node) + } +} + +export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => { + const hosted = new Date(value.time) + switch (name) { + case "getTime": + case "valueOf": + return value.time + case "toISOString": + if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node) + return hosted.toISOString() + case "toJSON": + return Number.isFinite(value.time) ? hosted.toISOString() : null + case "toString": + return coerceToString(value) + case "getFullYear": + return hosted.getFullYear() + case "getMonth": + return hosted.getMonth() + case "getDate": + return hosted.getDate() + case "getDay": + return hosted.getDay() + case "getHours": + return hosted.getHours() + case "getMinutes": + return hosted.getMinutes() + case "getSeconds": + return hosted.getSeconds() + case "getMilliseconds": + return hosted.getMilliseconds() + case "getUTCFullYear": + return hosted.getUTCFullYear() + case "getUTCMonth": + return hosted.getUTCMonth() + case "getUTCDate": + return hosted.getUTCDate() + case "getUTCDay": + return hosted.getUTCDay() + case "getUTCHours": + return hosted.getUTCHours() + case "getUTCMinutes": + return hosted.getUTCMinutes() + case "getUTCSeconds": + return hosted.getUTCSeconds() + case "getUTCMilliseconds": + return hosted.getUTCMilliseconds() + case "getTimezoneOffset": + return hosted.getTimezoneOffset() + default: + throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node) + } +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { SandboxDate } from "../values.js" +import { coerceToNumber, coerceToString } from "./value.js" diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a479d2c8c72b86298b402e9defee31659d1a93c --- /dev/null +++ b/packages/codemode/src/stdlib/json.ts @@ -0,0 +1,42 @@ +import { + type AstNode, + CodeModeFunction, + InterpreterRuntimeError, + supportedSyntaxMessage, +} from "../interpreter/model.js" +import { copyIn, copyOut } from "../tool-runtime.js" + +export const jsonStatics = new Set(["stringify", "parse"]) + +export const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { + if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) + switch (name) { + case "stringify": { + const replacer = args[1] + if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) { + throw new InterpreterRuntimeError( + "JSON.stringify replacers are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + const space = args[2] + const indent = typeof space === "number" || typeof space === "string" ? space : undefined + return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent) + } + case "parse": { + const text = args[0] + if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) + try { + return copyIn(JSON.parse(text), "JSON.parse result") + } catch (error) { + throw new InterpreterRuntimeError( + `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + node, + ).as("SyntaxError") + } + } + } + throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) +} diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc8dd0670e969929366e3fe8f07b700cc5f219f7 --- /dev/null +++ b/packages/codemode/src/stdlib/math.ts @@ -0,0 +1,65 @@ +export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) + +export const mathMethods = new Set([ + "max", + "min", + "abs", + "floor", + "ceil", + "round", + "trunc", + "sign", + "sqrt", + "cbrt", + "pow", + "hypot", + "log", + "log2", + "log10", + "exp", +]) + +export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { + if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) + const nums = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) + return arg + }) + const [a = Number.NaN, b = Number.NaN] = nums + switch (name) { + case "max": + return Math.max(...nums) + case "min": + return Math.min(...nums) + case "abs": + return Math.abs(a) + case "floor": + return Math.floor(a) + case "ceil": + return Math.ceil(a) + case "round": + return Math.round(a) + case "trunc": + return Math.trunc(a) + case "sign": + return Math.sign(a) + case "sqrt": + return Math.sqrt(a) + case "cbrt": + return Math.cbrt(a) + case "pow": + return Math.pow(a, b) + case "hypot": + return Math.hypot(...nums) + case "log": + return Math.log(a) + case "log2": + return Math.log2(a) + case "log10": + return Math.log10(a) + case "exp": + return Math.exp(a) + } + throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" diff --git a/packages/codemode/src/stdlib/number.ts b/packages/codemode/src/stdlib/number.ts new file mode 100644 index 0000000000000000000000000000000000000000..79710e1ad226672554233d09478cb84c9e706e6a --- /dev/null +++ b/packages/codemode/src/stdlib/number.ts @@ -0,0 +1,66 @@ +export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) + +export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) + +export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) + +export const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode): unknown => { + const optNum = (index: number): number | undefined => { + const arg = args[index] + if (arg === undefined) return undefined + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node) + return arg + } + let result: unknown + switch (name) { + case "toFixed": + result = value.toFixed(optNum(0)) + break + case "toExponential": + result = value.toExponential(optNum(0)) + break + case "toPrecision": { + const digits = optNum(0) + result = digits === undefined ? value.toString() : value.toPrecision(digits) + break + } + case "toString": { + const radix = optNum(0) + if (radix !== undefined && (radix < 2 || radix > 36)) { + throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node) + } + result = value.toString(radix) + break + } + default: + throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `Number.${name} result`) +} + +export const invokeNumberStatic = (name: string, args: Array, node: AstNode): unknown => { + const value = args[0] + switch (name) { + case "isInteger": + return Number.isInteger(value) + case "isFinite": + return Number.isFinite(value) + case "isNaN": + return Number.isNaN(value) + case "isSafeInteger": + return Number.isSafeInteger(value) + case "parseInt": { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") { + throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node) + } + return parseInt(coerceToString(value), radix) + } + case "parseFloat": + return parseFloat(coerceToString(value)) + default: + throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node) + } +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { boundedData, coerceToString } from "./value.js" diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts new file mode 100644 index 0000000000000000000000000000000000000000..2beda0bf5cae137b0715b61c603de05c6320f287 --- /dev/null +++ b/packages/codemode/src/stdlib/object.ts @@ -0,0 +1,77 @@ +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { isBlockedMember } from "../tool-runtime.js" +import { isSandboxValue, SandboxMap, SandboxURLSearchParams } from "../values.js" +import { boundedData, coerceToString } from "./value.js" + +export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"]) + +export const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { + if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) + const requireObject = (): Record => { + const value = boundedData(args[0], `Object.${name} input`) + if (isSandboxValue(value)) return {} + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) + } + return value as Record + } + const guardedSet = (out: Record, key: string, item: unknown): void => { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) + out[key] = item + } + switch (name) { + case "keys": { + const value = boundedData(args[0], "Object.keys input") + if (isSandboxValue(value)) return [] + if (Array.isArray(value)) return Object.keys(value) + if (value === null || typeof value !== "object") { + throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) + } + return Object.keys(value) + } + case "values": + return Object.values(requireObject()) + case "entries": + return Object.entries(requireObject()).map(([key, item]) => [key, item]) + case "hasOwn": + return Object.hasOwn(requireObject(), String(args[1])) + case "assign": { + const out: Record = Object.create(null) + for (const source of args) { + if (source === null || source === undefined) continue + const value = boundedData(source, "Object.assign input") + if (isSandboxValue(value)) continue + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new InterpreterRuntimeError("Object.assign expects data objects.", node) + } + for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) + } + return out + } + case "fromEntries": { + if (args[0] instanceof SandboxMap) { + const out: Record = Object.create(null) + for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item) + return out + } + if (args[0] instanceof SandboxURLSearchParams) { + const out: Record = Object.create(null) + for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value) + return out + } + const pairs = boundedData(args[0], "Object.fromEntries input") + if (!Array.isArray(pairs)) { + throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) + } + const out: Record = Object.create(null) + for (const pair of pairs) { + if (!Array.isArray(pair)) { + throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) + } + guardedSet(out, String(pair[0]), pair[1]) + } + return out + } + } + throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) +} diff --git a/packages/codemode/src/stdlib/promise.ts b/packages/codemode/src/stdlib/promise.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0b442ccf74f2230e0e8cbe060b0be86a120ce6a --- /dev/null +++ b/packages/codemode/src/stdlib/promise.ts @@ -0,0 +1,6 @@ +import type { PromiseMethodName } from "../interpreter/model.js" + +export const promiseStatics = new Set(["all", "allSettled", "race", "resolve", "reject"]) + +/** Maximum number of eagerly forked tool calls that may run concurrently. */ +export const TOOL_CALL_CONCURRENCY = 8 diff --git a/packages/codemode/src/stdlib/regexp.ts b/packages/codemode/src/stdlib/regexp.ts new file mode 100644 index 0000000000000000000000000000000000000000..eac123bc16c981317f508815b63922a213c16076 --- /dev/null +++ b/packages/codemode/src/stdlib/regexp.ts @@ -0,0 +1,74 @@ +export const regexpMethods = new Set(["test", "exec", "toString"]) + +export const regexpProperties = new Set([ + "source", + "flags", + "lastIndex", + "global", + "ignoreCase", + "multiline", + "sticky", + "unicode", + "dotAll", +]) + +export const regexFailureReason = (error: unknown): string => + (error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "") + +export const escapeRegexHint = + 'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.' + +export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => { + if (arg instanceof SandboxRegExp) return arg.regex + if (typeof arg === "string") { + try { + return new RegExp(arg, extraFlags) + } catch (error) { + throw new InterpreterRuntimeError( + `String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`, + node, + ).as("SyntaxError") + } + } + throw new InterpreterRuntimeError( + `String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`, + node, + ) +} + +export const matchToValue = (match: RegExpMatchArray): Array => { + const result: Array = Array.from(match, (group) => group) + if (match.index !== undefined) (result as Record & Array).index = match.index + if (match.groups) { + const groups: SafeObject = Object.create(null) as SafeObject + for (const [key, group] of Object.entries(match.groups)) { + if (!isBlockedMember(key)) groups[key] = group + } + ;(result as Record & Array).groups = groups + } + return result +} + +export const invokeRegExpMethod = ( + value: SandboxRegExp, + name: string, + args: Array, + node: AstNode, +): unknown => { + switch (name) { + case "test": + return value.regex.test(coerceToString(args[0])) + case "exec": { + const matched = value.regex.exec(coerceToString(args[0])) + return matched === null ? null : matchToValue(matched) + } + case "toString": + return coerceToString(value) + default: + throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node) + } +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { isBlockedMember, type SafeObject } from "../tool-runtime.js" +import { SandboxRegExp } from "../values.js" +import { coerceToString } from "./value.js" diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ac4372e363fe60ee9b68ad01780171f13d75fd5 --- /dev/null +++ b/packages/codemode/src/stdlib/string.ts @@ -0,0 +1,52 @@ +export const stringMethods = new Set([ + "toLowerCase", + "toUpperCase", + "trim", + "trimStart", + "trimEnd", + "trimLeft", + "trimRight", + "split", + "slice", + "substring", + "substr", + "includes", + "startsWith", + "endsWith", + "indexOf", + "lastIndexOf", + "replace", + "replaceAll", + "repeat", + "padStart", + "padEnd", + "charAt", + "charCodeAt", + "codePointAt", + "at", + "concat", + "toString", + "match", + "matchAll", + "search", + "localeCompare", + "normalize", +]) + +export const stringStatics = new Set(["fromCharCode", "fromCodePoint"]) + +export const invokeStringStatic = (name: string, args: Array, node: AstNode): unknown => { + const codes = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node) + return arg + }) + switch (name) { + case "fromCharCode": + return String.fromCharCode(...codes) + case "fromCodePoint": + return String.fromCodePoint(...codes) + default: + throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node) + } +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" diff --git a/packages/codemode/src/stdlib/url.ts b/packages/codemode/src/stdlib/url.ts new file mode 100644 index 0000000000000000000000000000000000000000..583d776d22ad434d7f41fe26c7f54461e0389425 --- /dev/null +++ b/packages/codemode/src/stdlib/url.ts @@ -0,0 +1,90 @@ +export const urlProperties = new Set([ + "href", + "origin", + "protocol", + "username", + "password", + "host", + "hostname", + "port", + "pathname", + "search", + "hash", +]) + +export const urlWritableProperties = new Set([ + "href", + "protocol", + "username", + "password", + "host", + "hostname", + "port", + "pathname", + "search", + "hash", +]) + +export const urlMethods = new Set(["toString", "toJSON"]) +export const urlStatics = new Set(["canParse", "parse"]) +export const urlSearchParamsMethods = new Set([ + "append", + "delete", + "get", + "getAll", + "has", + "set", + "sort", + "forEach", + "keys", + "values", + "entries", + "toString", +]) + +export const uriArgument = (value: unknown, label: string): string => coerceToString(boundedData(value, label)) + +export const invokeUriFunction = (ref: UriFunction, args: Array, node: AstNode): string => { + const value = uriArgument(args[0], `${ref.name} input`) + try { + switch (ref.name) { + case "encodeURI": + return encodeURI(value) + case "encodeURIComponent": + return encodeURIComponent(value) + case "decodeURI": + return decodeURI(value) + case "decodeURIComponent": + return decodeURIComponent(value) + } + } catch (error) { + throw new InterpreterRuntimeError( + `${ref.name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`, + node, + ).as("URIError") + } +} + +export const urlArgument = (value: unknown, label: string): string => + value instanceof SandboxURL ? value.url.href : uriArgument(value, label) + +export const invokeURLStatic = (name: string, args: Array, node: AstNode): unknown => { + if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node) + if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError") + const input = urlArgument(args[0], `URL.${name} input`) + const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`) + try { + const url = new URL(input, base) + return name === "canParse" ? true : new SandboxURL(url) + } catch { + return name === "canParse" ? false : null + } +} + +export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => { + if (name === "toString" || name === "toJSON") return value.url.href + throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node) +} +import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js" +import { SandboxURL } from "../values.js" +import { boundedData, coerceToString } from "./value.js" diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab40dc07ddb5f41a8ad944e06c0ce34234e8b453 --- /dev/null +++ b/packages/codemode/src/stdlib/value.ts @@ -0,0 +1,86 @@ +export const errorConstructors = new Set([ + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "EvalError", + "URIError", +]) + +export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"]) + +export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) + +const ErrorBrand: unique symbol = Symbol("codemode.error") + +export const createErrorValue = (name: string, message: string): SafeObject => { + const value = Object.assign(Object.create(null) as SafeObject, { name, message }) + Object.defineProperty(value, ErrorBrand, { value: name }) + return value +} + +export const errorBrandName = (value: unknown): string | undefined => + value !== null && typeof value === "object" + ? ((value as Record)[ErrorBrand] as string | undefined) + : undefined + +export const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true) + +export const coerceToString = (value: unknown): string => { + if (value === null) return "null" + if (value === undefined) return "undefined" + if (value instanceof SandboxDate) + return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" + if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}` + if (value instanceof SandboxMap) return "[object Map]" + if (value instanceof SandboxSet) return "[object Set]" + if (value instanceof SandboxURL) return value.url.href + if (value instanceof SandboxURLSearchParams) return value.params.toString() + if (typeof value === "object") { + return Array.isArray(value) + ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") + : "[object Object]" + } + return String(value) +} + +export const coerceToNumber = (value: unknown): number => { + if (value instanceof SandboxDate) return value.time + if (isSandboxValue(value)) return Number.NaN + return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) +} + +export const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode): unknown => { + const raw = args[0] + if (isSandboxValue(raw)) { + if (ref.name === "Boolean") return true + if (ref.name === "Number") return coerceToNumber(raw) + if (ref.name === "String") return coerceToString(raw) + if (ref.name === "parseInt") return parseInt(coerceToString(raw)) + return parseFloat(coerceToString(raw)) + } + const value = boundedData(args[0], `${ref.name} input`) + if (ref.name === "Number") return coerceToNumber(value) + if (ref.name === "Boolean") return Boolean(value) + if (ref.name === "parseInt") { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") { + throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node) + } + return parseInt(coerceToString(value), radix) + } + if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) + return coerceToString(value) +} +import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js" +import { copyIn, type SafeObject } from "../tool-runtime.js" +import { + isSandboxValue, + SandboxDate, + SandboxMap, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../values.js" diff --git a/packages/codemode/src/tool-error.ts b/packages/codemode/src/tool-error.ts new file mode 100644 index 0000000000000000000000000000000000000000..16460a019ae1e901a29ac4a3dda872d75acfcf0a --- /dev/null +++ b/packages/codemode/src/tool-error.ts @@ -0,0 +1,11 @@ +import { Schema } from "effect" + +/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */ +export class ToolError extends Schema.TaggedErrorClass()("ToolError", { + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), +}) {} + +/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */ +export const toolError = (message: string, cause?: unknown): ToolError => + new ToolError({ message, ...(cause === undefined ? {} : { cause }) }) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4ccc61d4c49a4f7572906559e5a4e2a11acdec9 --- /dev/null +++ b/packages/codemode/src/tool-runtime.ts @@ -0,0 +1,806 @@ +import { Cause, Effect, Schema } from "effect" +import { ToolError, toolError } from "./tool-error.js" +import { + decodeInput as decodeToolInput, + decodeOutput as decodeToolOutput, + identifierSegment, + inputProperties, + inputTypeScript, + outputTypeScript, +} from "./tool-schema.js" +import { isDefinition as isToolDefinition, type Definition } from "./tool.js" +import { + SandboxDate, + SandboxMap, + SandboxPromise, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "./values.js" + +const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) + +export type HostTool = (...args: Array) => Effect.Effect + +export type HostTools = { + [name: string]: HostTool | Definition | HostTools +} + +export type Services = ServicesOf + +type ServicesOf> = Depth["length"] extends 8 + ? never + : Tools extends (...args: Array) => Effect.Effect + ? R + : Tools extends { + readonly _tag: "CodeModeTool" + readonly run: (input: unknown) => Effect.Effect + } + ? R + : Tools extends object + ? string extends keyof Tools + ? ServicesOf + : ServicesOf + : never + +/** Minimal audit record retained for each admitted tool call. */ +export type ToolCall = { + readonly name: string +} + +/** Decoded tool call observed immediately before tool execution. */ +export type ToolCallStarted = { + readonly index: number + readonly name: string + readonly input: unknown +} + +/** Completed tool call observed immediately after tool execution settles. */ +export type ToolCallEnded = { + readonly index: number + readonly name: string + readonly input: unknown + readonly durationMs: number + readonly outcome: "success" | "failure" + /** Model-safe failure message; present only when `outcome` is `"failure"`. */ + readonly message?: string +} + +/** Non-throwing observation hooks fired around each admitted tool call. */ +export type ToolCallHooks = { + readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect) | undefined + readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect) | undefined +} + +/** Model-visible description of one schema-backed tool. */ +export type ToolDescription = { + readonly path: string + readonly description: string + readonly signature: string +} + +export type SafeObject = Record + +const reservedNamespace = "$codemode" +const defaultCatalogBudget = 2_000 +const defaultSearchLimit = 10 +const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) +const SearchInput = Schema.Struct({ + query: Schema.optionalKey(Schema.String), + namespace: Schema.optionalKey(Schema.String), + limit: Schema.optionalKey(PositiveInt), + offset: Schema.optionalKey(NonNegativeInt), +}) +const SearchItem = Schema.Struct({ + path: Schema.String, + description: Schema.String, + signature: Schema.String, +}) +const SearchOutput = Schema.Struct({ + items: Schema.Array(SearchItem), + remaining: NonNegativeInt, + next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })), +}) +const toolExpression = (path: string) => + "tools" + + path + .split(".") + .map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)) + .join("") + +export class ToolReference { + constructor(readonly path: ReadonlyArray) {} +} + +/** + * Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable + * limit) purely because it produces a clearer diagnostic than a native stack-overflow + * RangeError would. + */ +const MAX_VALUE_DEPTH = 32 + +export class ToolRuntimeError extends Error { + constructor( + readonly kind: + | "UnknownTool" + | "InvalidToolInput" + | "InvalidToolOutput" + | "InvalidDataValue" + | "ToolCallLimitExceeded", + message: string, + readonly suggestions: ReadonlyArray = [], + ) { + super(message) + this.name = "ToolRuntimeError" + } +} + +const isDefinition = (value: HostTool | Definition | HostTools): value is Definition => + isToolDefinition(value) + +const runHost = (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt + const error = Cause.squash(cause) + return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error)) + }), + ) + +const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) + +export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) + +/** + * Validates and copies a value against the plain-data contract (depth, circularity, plain + * objects only, blocked properties, data-only leaves). + * + * Two modes share the walk: + * - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary - + * final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize + * exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}. + * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in + * codemode.ts): standard-library value instances pass through untouched (treated as leaves, + * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and + * other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...). + * + * Both modes reject un-awaited promises with an await-hinting diagnostic. + */ +export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => + copyBounded(value, label, 0, new Set(), preserveSandboxValues) + +const copyBounded = ( + value: unknown, + label: string, + depth: number, + seen: Set, + preserveSandboxValues: boolean, +): unknown => { + if (depth > MAX_VALUE_DEPTH) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) + } + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "boolean" || + // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real + // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are + // normalized to `null` when the value leaves the sandbox - see copyOut - exactly as + // JSON.stringify already does at any tool boundary. + typeof value === "number" + ) { + return value + } + + if (typeof value !== "object") { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) + } + + // An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the + // model exactly how to fix the program instead. + if (value instanceof SandboxPromise) { + throw new ToolRuntimeError( + "InvalidDataValue", + `${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`, + ) + } + + if (preserveSandboxValues) { + // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents + // are never walked here (Map/Set members are validated where mutation happens, and the + // real boundary still serializes them below). + if ( + value instanceof SandboxDate || + value instanceof SandboxRegExp || + value instanceof SandboxMap || + value instanceof SandboxSet || + value instanceof SandboxURL || + value instanceof SandboxURLSearchParams + ) { + return value + } + // Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross + // the boundary first), but wrap them defensively rather than degrading to JSON forms. + if (value instanceof Date) return new SandboxDate(value.getTime()) + if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags) + if (value instanceof Map) { + const wrapped = new SandboxMap() + for (const [key, item] of value.entries()) { + wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true)) + } + return wrapped + } + if (value instanceof Set) { + const wrapped = new SandboxSet() + for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true)) + return wrapped + } + if (value instanceof URL) return new SandboxURL(new URL(value.href)) + if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value)) + } + + // Sandbox value types (and their host counterparts, which a host tool may legitimately + // return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use + // toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}. + if (value instanceof SandboxDate) { + return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null + } + if (value instanceof Date) { + return Number.isFinite(value.getTime()) ? value.toISOString() : null + } + if (value instanceof SandboxURL) return value.url.href + if (value instanceof URL) return value.href + if ( + value instanceof SandboxRegExp || + value instanceof SandboxMap || + value instanceof SandboxSet || + value instanceof SandboxURLSearchParams || + value instanceof RegExp || + value instanceof Map || + value instanceof Set || + value instanceof URLSearchParams + ) { + return Object.create(null) as SafeObject + } + + if (seen.has(value)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) + } + + seen.add(value) + + if (Array.isArray(value)) { + const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues)) + seen.delete(value) + return copied + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`) + } + + const copied: SafeObject = Object.create(null) as SafeObject + for (const [key, item] of Object.entries(value)) { + if (isBlockedMember(key)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) + } + copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues) + } + seen.delete(value) + return copied +} + +export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { + if (value === undefined && undefinedAsNull) return null + // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return + // and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity + // have no JSON representation, so JSON.stringify would produce null anyway. + if (typeof value === "number" && !Number.isFinite(value)) { + return null + } + if (Array.isArray(value)) { + return value.map((item) => copyOut(item, undefinedAsNull)) + } + + if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)])) + } + + return value +} + +const definitions = ( + tools: HostTools, + path: ReadonlyArray = [], +): Array<{ path: string; definition: Definition }> => { + const entries: Array<{ path: string; definition: Definition }> = [] + for (const [name, value] of Object.entries(tools)) { + const next = [...path, name] + if (isDefinition(value)) entries.push({ path: next.join("."), definition: value }) + else if (typeof value !== "function") entries.push(...definitions(value, next)) + } + return entries +} + +const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ + path, + description: definition.description, + signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, +}) + +const visibleDefinitions = (tools: HostTools) => + definitions(tools).map(({ path, definition }) => ({ + path, + definition, + description: describeDefinition(path, definition), + })) + +export const catalog = (tools: HostTools): ReadonlyArray => + visibleDefinitions(tools).map(({ description }) => description) + +export type DiscoveryPlan = { + readonly catalog: ReadonlyArray + readonly instructions: string + readonly searchIndex: ReadonlyArray +} + +export type SearchEntry = { + readonly description: ToolDescription + /** Top-level namespace (first path segment), matched by the search `namespace` option. */ + readonly namespace: string + /** Lowercased path + description + input property names/descriptions, for substring matching. */ + readonly searchText: string +} + +/** + * Split a query into lowercased search terms. camelCase boundaries are split + * (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a + * separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all + * tokenize alike. Empties and the `*` wildcard are dropped. + */ +const tokenize = (query: string): Array => + query + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((term) => term.length > 0 && term !== "*") + +/** + * A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural + * query term ("issues") still matches indexed text that only carries the singular + * ("issue"). Matching is one-directional substring containment, so the variants are + * needed only on the query side; scoring weights are unchanged - each field check + * passes when ANY form matches. + */ +const termForms = (term: string): Array => { + const forms = [term] + if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2)) + if (term.endsWith("s") && term.length > 2) forms.push(term.slice(0, -1)) + return forms +} + +const makeSearchTool = (searchIndex: ReadonlyArray): Definition => ({ + _tag: "CodeModeTool", + description: "Search available Code Mode tools", + input: SearchInput, + output: SearchOutput, + run: (input) => + Effect.sync(() => { + const request = input as typeof SearchInput.Type + const query = request.query ?? "" + const offset = request.offset ?? 0 + const scoped = + request.namespace === undefined + ? searchIndex + : searchIndex.filter((entry) => entry.namespace === request.namespace) + // A query that names one tool path exactly (canonical path or rendered JavaScript + // expression) is a lookup, not a search: return that tool alone. + const trimmed = query.trim() + const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed + const exact = + pathQuery === "" + ? undefined + : scoped.find( + (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, + ) + const terms = tokenize(query).map(termForms) + // Additive field-weighted scoring, summed across terms: exact path or path segment + // (20) > path substring (8) > description substring (4) > any searchable text, + // including input parameter names and descriptions (2). + const ranked = + exact !== undefined + ? [exact] + : scoped + .map((entry) => { + const path = entry.description.path.toLowerCase() + const description = entry.description.description.toLowerCase() + const score = terms.reduce( + (total, forms) => + total + + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + + (forms.some((form) => path.includes(form)) ? 8 : 0) + + (forms.some((form) => description.includes(form)) ? 4 : 0) + + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), + 0, + ) + return { entry, score } + }) + .filter(({ score }) => terms.length === 0 || score > 0) + .sort( + (left, right) => + right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path), + ) + .map(({ entry }) => entry) + const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({ + ...description, + path: toolExpression(description.path), + })) + const remaining = Math.max(0, ranked.length - offset - items.length) + return { + items, + remaining, + next: remaining > 0 ? { offset: offset + items.length } : null, + } + }), +}) + +const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([])) + +const catalogLine = (tool: ToolDescription) => { + // Keep the tool description concise; the full schema documentation remains in the signature. + const line = tool.description.split("\n", 1)[0]!.trim() + const description = line.length > 120 ? line.slice(0, 119) + "..." : line + return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` +} + +const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ + description, + namespace: path.split(".", 1)[0]!, + searchText: [ + path, + definition.description, + ...inputProperties(definition).flatMap(({ name, description: property }) => + property === undefined ? [name] : [name, property], + ), + ] + .join("\n") + .toLowerCase(), +}) + +/** The runtime search index over every described tool. Search is always registered. */ +export const searchIndex = (tools: HostTools): ReadonlyArray => + visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) + +export const assertValidTools = (tools: HostTools): void => { + if (Object.hasOwn(tools, reservedNamespace)) { + throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`) + } +} + +/** + * Budgeted catalog: every namespace is always listed with its tool count; full call + * signatures are inlined against the `catalogBudget` (estimated tokens, + * chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every + * namespace still holding un-inlined tools attempts to place its next-cheapest line, and + * a namespace whose next line does not fit is done while the others keep going - so every + * namespace gets some representation before any namespace gets everything. The section + * states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per + * namespace. Namespace stub lines are never budgeted: every namespace appears with its + * tool count even at budget 0. + */ +export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { + if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { + throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") + } + const visible = visibleDefinitions(tools) + const described = visible.map(({ description }) => description) + + const namespaces = new Map>() + for (const tool of described) { + const [namespace = tool.path] = tool.path.split(".") + const group = namespaces.get(namespace) ?? [] + group.push(tool) + namespaces.set(namespace, group) + } + const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right)) + + // Select which signatures fit the budget before emitting, so the list can state + // exactly how comprehensive it is. Round-robin fairness: in each round (namespaces + // alphabetical), every namespace still holding un-inlined tools tries to place its + // next-cheapest line against the shared budget; a namespace whose next line does not + // fit is done - the others keep going - so every namespace gets some representation + // before any namespace gets everything. + const selections = ordered.map(([namespace, group]) => ({ + namespace, + picked: new Set(), + queue: [...group].sort( + (left, right) => + estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path), + ), + })) + let used = 0 + let active = selections.filter((selection) => selection.queue.length > 0) + while (active.length > 0) { + const stillActive: typeof active = [] + for (const selection of active) { + const tool = selection.queue[0]! + const cost = estimateTokens(catalogLine(tool)) + if (used + cost > catalogBudget) continue + selection.queue.shift() + selection.picked.add(tool) + used += cost + if (selection.queue.length > 0) stillActive.push(selection) + } + active = stillActive + } + const shown = new Map>( + selections.map(({ namespace, picked }) => [namespace, picked]), + ) + const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0) + const complete = totalShown === described.length + + const empty = described.length === 0 + + // Section order is deliberate: workflow first (the top is the least likely part of a long + // description to be truncated or skimmed away), then rules, then syntax, with the budgeted + // catalog at the bottom. Example call forms use placeholders - never a real or fabricated + // tool name - and show both dot and bracket notation so non-identifier names are not normalized. + const intro = [ + empty + ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime." + : complete + ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available." + : "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.", + ...(empty + ? [] + : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]), + ] + + // The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE + // catalog already shows every signature, so step 1 picks from the list instead. + const workflow = empty + ? [] + : [ + "", + "## Workflow", + "", + ...(complete + ? [ + "1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.", + "2. Call it using the exact signature shown: `const result = await tools..(input)`; bracket notation and quotes are part of the path.", + "3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.", + ] + : [ + '1. If needed, discover tools: `return await tools.$codemode.search({ query: "" })`.', + "2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.", + ]), + ] + + const rules = empty + ? [] + : [ + "", + "## Rules", + "", + complete + ? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed." + : "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.", + "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.", + "- A result typed `Promise` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.", + '- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`, or use `tools.["tool-name"](item)` when the listed signature uses bracket notation.', + "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", + ...(complete + ? [] + : [ + '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', + "- If search returns `next`, repeat the same search with `offset: next.offset`.", + ]), + ] + + const language = [ + "", + "## Language", + "", + "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", + "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", + "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", + ] + + const toolSection: Array = [""] + if (empty) { + toolSection.push("## Available tools", "", "No tools are currently available.") + } else { + toolSection.push( + complete + ? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)" + : `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`, + "", + ) + for (const [namespace, group] of ordered) { + const picked = shown.get(namespace)! + const count = `${group.length} tool${group.length === 1 ? "" : "s"}` + // Annotate only when a namespace is not fully shown, so a comprehensive + // namespace reads cleanly and a truncated one is unambiguous. + const label = + picked.size === group.length + ? count + : picked.size === 0 + ? `${count}, none shown` + : `${count}, ${picked.size} shown` + toolSection.push(`- ${namespace} (${label})`) + for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) + } + if (!complete) { + toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`) + } + } + + const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection] + return { + catalog: described, + instructions: lines.join("\n"), + searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)), + } +} + +/** + * The enumerable names at one node of the callable tool tree - namespace names at the root, + * tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool + * references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a + * function in JS). An unknown path is an `UnknownTool` error pointing at the working + * discovery idioms, mirroring how calling an unknown tool fails. + */ +const namespaceKeys = (tools: HostTools, path: ReadonlyArray): ReadonlyArray => { + let value: HostTool | Definition | HostTools = tools + for (const segment of path) { + if ( + isBlockedMember(segment) || + typeof value === "function" || + isDefinition(value) || + !Object.hasOwn(value, segment) + ) { + throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [ + "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.", + ]) + } + value = value[segment] as HostTool | Definition | HostTools + } + if (typeof value === "function" || isDefinition(value)) return [] + return Object.keys(value) +} + +const resolve = (tools: HostTools, path: ReadonlyArray): HostTool | Definition => { + let value: HostTool | Definition | HostTools = tools + + for (const segment of path) { + if ( + isBlockedMember(segment) || + typeof value === "function" || + isDefinition(value) || + !Object.hasOwn(value, segment) + ) { + throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [ + "Use tools.$codemode.search({ query }) to find available described tools.", + ]) + } + value = value[segment] as HostTool | Definition | HostTools + } + + if (typeof value !== "function" && !isDefinition(value)) { + throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`) + } + + return value +} + +export type ToolRuntime = { + readonly root: ToolReference + readonly calls: Array + readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect + /** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */ + readonly keys: (path: ReadonlyArray) => ReadonlyArray +} + +export const make = ( + tools: HostTools, + /** Undefined means unlimited tool calls. */ + maxToolCalls: number | undefined, + searchIndex: ReadonlyArray, + hooks?: ToolCallHooks, +): ToolRuntime => { + const calls: Array = [] + const callableTools = { + ...tools, + [reservedNamespace]: { search: makeSearchTool(searchIndex) }, + } + + // Wraps the settling portion of a tool call so onToolCallEnd observes success and failure + // symmetrically. Interruption (e.g. the execution timeout) fires neither outcome. + const observeEnd = (effect: Effect.Effect, call: ToolCallStarted): Effect.Effect => { + const onEnd = hooks?.onToolCallEnd + if (onEnd === undefined) return effect + const startedAt = Date.now() + return effect.pipe( + Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })), + Effect.tapError((error) => { + const message = + error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed" + return onEnd({ + ...call, + durationMs: Date.now() - startedAt, + outcome: "failure", + message, + }) + }), + ) + } + + const decodeOutput = (value: unknown, name: string) => + Effect.try({ + try: () => copyIn(value, `Result from tool '${name}'`), + catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), + }) + + const recordCall = (call: ToolCall): void => { + if (maxToolCalls !== undefined && calls.length >= maxToolCalls) { + throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`) + } + calls.push(call) + } + + return { + root: new ToolReference([]), + calls, + keys: (path) => namespaceKeys(callableTools, path), + invoke: (path, args) => + Effect.gen(function* () { + const name = path.join(".") + const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) + const call = { name } + const recordAndObserve = (input: unknown) => + Effect.sync(() => { + recordCall(call) + return calls.length - 1 + }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) + const tool = resolve(callableTools, path) + let describedInput: unknown + if (isDefinition(tool)) { + if (externalArgs.length !== 1) + throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) + describedInput = yield* Effect.try({ + try: () => decodeToolInput(tool, externalArgs[0]), + catch: (cause) => + new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), + }) + } + const input = isDefinition(tool) ? describedInput : externalArgs + const index = yield* recordAndObserve(input) + const currentCall = { index, name, input } + if (isDefinition(tool)) { + return yield* observeEnd( + Effect.gen(function* () { + const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput))) + const result = yield* Effect.try({ + try: () => decodeToolOutput(tool, raw), + catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), + }) + return yield* decodeOutput(result, name) + }), + currentCall, + ) + } + return yield* observeEnd( + Effect.gen(function* () { + return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name) + }), + currentCall, + ) + }), + } +} + +export * as ToolRuntime from "./tool-runtime.js" diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..16213fa8eeb2d146350a368aeab13e7bf923a16a --- /dev/null +++ b/packages/codemode/src/tool-schema.ts @@ -0,0 +1,301 @@ +import { JsonPointer, Schema } from "effect" +import type { Definition, JsonSchema, SchemaType } from "./tool.js" + +const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) + +const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" + +/** + * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime, + * with dot access as a tool-path segment). Anything else must be quoted/bracketed. + */ +export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ +const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) + +const effectNumberSentinel = (schema: JsonSchema) => + schema.type === "string" && + Array.isArray(schema.enum) && + schema.enum.length === 1 && + (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") + +const intersection = (members: ReadonlyArray): string => { + const concrete = members.filter((member) => member !== "unknown") + if (concrete.length === 0) return "unknown" + if (concrete.length === 1) return concrete[0] ?? "unknown" + return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ") +} + +/** + * Recursion ceiling for schema rendering. Object, array, and union recursion all increment + * depth, so this bounds every recursion path - pathological or structurally cyclic schemas + * degrade to `unknown` instead of overflowing the stack (rendering must never throw). + */ +const MAX_RENDER_DEPTH = 8 + +type RenderContext = { + readonly definitions: Readonly> + /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ + readonly pretty: boolean +} + +const hasUnresolvedRef = ( + schema: JsonSchema, + definitions: Readonly>, + seen: ReadonlySet = new Set(), + visited: ReadonlySet = new Set(), +): boolean => { + if (visited.has(schema)) return false + const nextVisited = new Set([...visited, schema]) + if (schema.$ref !== undefined) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + if (name === undefined || definitions[name] === undefined || seen.has(name)) return true + if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true + } + return [ + ...(schema.anyOf ?? []), + ...(schema.oneOf ?? []), + ...(schema.allOf ?? []), + ...Object.values(schema.properties ?? {}), + ...(schema.items === undefined ? [] : [schema.items]), + ...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []), + ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited)) +} + +/** + * Schema constraints a TypeScript type cannot express natively but a model benefits from, + * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). + */ +const docTags = (schema: JsonSchema): Array => { + const tags: Array = [] + if (schema.deprecated === true) tags.push("@deprecated") + if (schema.default !== undefined) { + try { + const rendered = JSON.stringify(schema.default) + if (rendered !== undefined) tags.push(`@default ${rendered}`) + } catch { + // unserializable default: skip rather than emit a broken tag + } + } + if (typeof schema.format === "string") tags.push(`@format ${schema.format}`) + if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`) + if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`) + return tags +} + +/** + * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, + * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a + * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and + * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so + * callers can prepend it directly to the field line. + */ +const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { + const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => + line.replaceAll("*/", "* /").replace(/\s+$/, ""), + ) + while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() + while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() + if (lines.length === 0) return "" + if (lines.length === 1) return `${pad}/** ${lines[0]} */\n` + const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n") + return `${pad}/**\n${body}\n${pad} */\n` +} + +const renderSchema = ( + schema: JsonSchema, + ctx: RenderContext, + depth = 0, + seen: ReadonlySet = new Set(), +): string => { + if (depth > MAX_RENDER_DEPTH) return "unknown" + const nested = + schema.definitions === undefined && schema.$defs === undefined + ? ctx + : { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } } + if (schema.$ref) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + if (!name || !nested.definitions[name] || seen.has(name)) return "unknown" + return intersection([ + renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])), + renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen), + ]) + } + if (schema.const !== undefined) return renderLiteral(schema.const) + if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") + const alternatives = schema.anyOf ?? schema.oneOf + if (alternatives) { + // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" }, + // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact; + // real JSON Schema unions such as `string | number` or `number | null` must keep + // every branch. + if ( + alternatives.some((item) => item.type === "number") && + alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) + ) + return "number" + // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` + // (no properties/items); render the bare shape as {} instead of `{} | Array`. + if ( + alternatives.length === 2 && + alternatives[0]?.type === "object" && + alternatives[0].properties === undefined && + alternatives[1]?.type === "array" && + alternatives[1].items === undefined + ) { + return "{}" + } + const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen)) + if (members.some((member) => member === "unknown")) return "unknown" + return intersection([ + members.join(" | "), + renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen), + ]) + } + if (schema.allOf) { + const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen)) + if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown" + return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members]) + } + if (Array.isArray(schema.type)) { + return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ") + } + if (schema.type === "string") return "string" + if (schema.type === "number" || schema.type === "integer") return "number" + if (schema.type === "boolean") return "boolean" + if (schema.type === "null") return "null" + if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>` + if (schema.type === "object" || schema.properties) { + const required = new Set(schema.required ?? []) + const properties = Object.entries(schema.properties ?? {}) + const additional = schema.additionalProperties + const indexType = + additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined + const field = ([name, value]: readonly [string, JsonSchema]) => + `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}` + + if (!ctx.pretty) { + const fields = properties.map(field) + if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`) + return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` + } + + // Pretty: an indented block, each described field preceded by its JSDoc comment. + if (properties.length === 0 && indexType === undefined) return "{}" + const pad = " ".repeat(depth + 1) + const lines = properties.map( + (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`, + ) + if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`) + return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` + } + return "unknown" +} + +export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => { + try { + const visible = decoded ? Schema.toType(schema) : schema + const document = Schema.toJsonSchemaDocument(visible) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + } + return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty }) + } catch { + return "unknown" + } +} + +/** Renders a raw JSON Schema document as a TypeScript type string. */ +export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { + try { + return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty }) + } catch { + return "unknown" + } +} + +/** One input property of a tool, extracted best-effort from its input schema. */ +export type InputProperty = { + readonly name: string + readonly description: string | undefined + readonly required: boolean +} + +/** + * The property names, descriptions, and required flags of a tool's input schema - the raw + * material for search text. Best-effort: Effect Schemas go through their + * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read + * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. + * Anything unresolvable yields `[]` (search falls back to path + description). + */ +export const inputProperties = (definition: Definition): Array => { + try { + const document = isEffectSchema(definition.input) + ? (Schema.toJsonSchemaDocument(definition.input) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + }) + : { + schema: definition.input, + definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) }, + } + const definitions = document.definitions ?? {} + let schema = document.schema + if (schema.$ref !== undefined) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + const resolved = name === undefined ? undefined : definitions[name] + if (resolved === undefined) return [] + schema = resolved + } + const required = new Set(schema.required ?? []) + return Object.entries(schema.properties ?? {}).map(([name, value]) => ({ + name, + description: typeof value.description === "string" ? value.description : undefined, + required: required.has(name), + })) + } catch { + return [] + } +} + +/** + * The model-visible TypeScript type of a tool's input. `pretty` renders an indented + * multiline block with schema descriptions and constraints as JSDoc comments on the + * fields; the default stays the compact single-line form. + */ +export const inputTypeScript = (definition: Definition, pretty = false): string => + isEffectSchema(definition.input) + ? toTypeScript(definition.input, false, pretty) + : jsonSchemaToTypeScript(definition.input, pretty) + +/** + * The model-visible TypeScript type of a tool's result; tools without an output schema + * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs. + */ +export const outputTypeScript = (definition: Definition, pretty = false): string => + definition.output === undefined + ? "unknown" + : isEffectSchema(definition.output) + ? toTypeScript(definition.output, true, pretty) + : jsonSchemaToTypeScript(definition.output, pretty) + +/** + * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); + * JSON-Schema-described inputs pass through unvalidated (render-only). + */ +export const decodeInput = (definition: Definition, value: unknown): unknown => + isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value + +/** + * Decodes a tool result before it is exposed to the program. Effect Schemas validate and + * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass + * the host value through unchanged. + */ +export const decodeOutput = (definition: Definition, value: unknown): unknown => + definition.output !== undefined && isEffectSchema(definition.output) + ? Schema.decodeUnknownSync(definition.output)(value) + : value diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c6863f99dcf7e659a4e1a2b582095694404e39c --- /dev/null +++ b/packages/codemode/src/tool.ts @@ -0,0 +1,96 @@ +import { Effect, Schema } from "effect" + +/** + * JSON Schema subset accepted for render-only tool schemas. + * + * A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript + * signature only - CodeMode performs no validation against it. This is the natural shape for + * adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents. + */ +export type JsonSchema = { + readonly type?: string | ReadonlyArray + readonly enum?: ReadonlyArray + readonly const?: unknown + readonly anyOf?: ReadonlyArray + readonly oneOf?: ReadonlyArray + readonly allOf?: ReadonlyArray + readonly properties?: Readonly> + readonly required?: ReadonlyArray + readonly items?: JsonSchema + readonly additionalProperties?: boolean | JsonSchema + readonly description?: string + readonly default?: unknown + readonly format?: string + readonly deprecated?: boolean + readonly minItems?: number + readonly maxItems?: number + readonly $ref?: string + readonly $defs?: Readonly> + readonly definitions?: Readonly> +} + +/** Either a validating Effect Schema or a render-only JSON Schema document. */ +export type SchemaType = Schema.Decoder | JsonSchema + +/** Schema-backed tool definition consumed by a CodeMode tool tree. */ +export type Definition = { + readonly _tag: "CodeModeTool" + readonly description: string + readonly input: SchemaType + readonly output: SchemaType | undefined + readonly run: (input: unknown) => Effect.Effect +} + +/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */ +type InputType = S extends Schema.Decoder ? S["Type"] : unknown + +/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */ +type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown + +/** Options for defining one CodeMode tool. */ +export type Options = { + readonly description: string + readonly input: I + readonly output?: O + readonly run: (input: InputType) => Effect.Effect, unknown, R> +} + +export const isDefinition = (value: unknown): value is Definition => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool" + +/** + * Defines one schema-described tool available to a CodeMode program through `tools.*`. + * + * `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema + * document. Effect Schema input is decoded before `run` is invoked, and `run` returns the + * encoded representation of an Effect Schema `output`, which CodeMode decodes before returning + * it to the program. JSON Schemas only shape the model-visible signature; values pass through + * unvalidated. `output` is optional - without it the signature advertises `unknown` and the + * host result is exposed as-is. The host tool remains responsible for authorization and + * durable side-effect handling. + * + * @example + * ```ts + * const lookup = Tool.make({ + * description: "Look up an order", + * input: Schema.Struct({ id: Schema.String }), + * output: Schema.Struct({ status: Schema.String }), + * run: ({ id }) => Effect.succeed({ status: "open" }), + * }) + * + * const fromJsonSchema = Tool.make({ + * description: "Call an adapter-described tool", + * input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + * run: (input) => callHost(input), + * }) + * ``` + */ +export const make = ( + options: Options, +): Definition => ({ + _tag: "CodeModeTool", + description: options.description, + input: options.input, + output: options.output, + run: (input) => options.run(input as InputType), +}) diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ca305d815ebf5cce00f396e79e657a88fcb26a0 --- /dev/null +++ b/packages/codemode/src/values.ts @@ -0,0 +1,49 @@ +import type { Effect, Fiber } from "effect" + +export class SandboxPromise { + interrupted = false + constructor( + readonly fiber: Fiber.Fiber | undefined, + readonly immediate?: Effect.Effect, + ) {} +} + +export class SandboxDate { + constructor(readonly time: number) {} +} + +export class SandboxRegExp { + readonly regex: RegExp + constructor(pattern: string, flags: string) { + this.regex = new RegExp(pattern, flags) + } +} + +export class SandboxMap { + readonly map = new Map() +} + +export class SandboxSet { + readonly set = new Set() +} + +export class SandboxURLSearchParams { + constructor(readonly params: URLSearchParams) {} +} + +export class SandboxURL { + readonly searchParams: SandboxURLSearchParams + constructor(readonly url: URL) { + this.searchParams = new SandboxURLSearchParams(url.searchParams) + } +} + +export const isSandboxValue = ( + value: unknown, +): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams => + value instanceof SandboxDate || + value instanceof SandboxRegExp || + value instanceof SandboxMap || + value instanceof SandboxSet || + value instanceof SandboxURL || + value instanceof SandboxURLSearchParams diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..221b5e07dfe1f6be5239dbcd660050435516bca1 --- /dev/null +++ b/packages/codemode/test/codemode.test.ts @@ -0,0 +1,1163 @@ +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Schema } from "effect" +import { CodeMode, Tool, toolError } from "../src/index.js" + +const run = (tool: Tool.Definition) => + Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})")) + +class UnsafeHostError extends Schema.TaggedErrorClass()("UnsafeHostError", { + reason: Schema.String, +}) {} + +describe("CodeMode host failure boundary", () => { + test("preserves explicit safe tool failures", async () => { + const result = await run( + Tool.make({ + description: "Fail safely", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Authorized request was refused")), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "ToolFailure", + message: "Authorized request was refused", + }) + }) + + test("sanitizes unknown host failures and defects", async () => { + for (const failure of [ + Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })), + Effect.die(new Error("postgres://user:defect-secret@example.invalid")), + ]) { + const result = await run( + Tool.make({ + description: "Fail internally", + input: Schema.Struct({}), + output: Schema.String, + run: () => failure, + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "ToolFailure", + message: "Tool execution failed", + }) + expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/) + } + }) + + test("sanitizes invalid host output", async () => { + const secret = "invalid-output-secret" + const result = await run( + Tool.make({ + description: "Return invalid output", + input: Schema.Struct({}), + output: Schema.Struct({ safe: Schema.String }), + run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "InvalidToolOutput", + message: "Invalid output from tool 'host.call'.", + }) + expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/) + }) + + test("sanitizes host output that throws while being copied", async () => { + const result = await run( + Tool.make({ + description: "Return hostile output", + input: Schema.Struct({}), + output: Schema.Unknown, + run: () => + Effect.succeed( + new Proxy( + {}, + { + ownKeys: () => { + throw new Error("host-output-secret") + }, + }, + ), + ), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "InvalidToolOutput", + message: "Invalid output from tool 'host.call'.", + }) + expect(JSON.stringify(result)).not.toMatch(/host-output-secret/) + }) + + test("caught tool failures are Error values in-program", async () => { + const result = await Effect.runPromise( + CodeMode.make({ + tools: { + host: { + call: Tool.make({ + description: "Refuse", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Refused")), + }), + }, + }, + }).execute(` + try { + await tools.host.call({}) + return "no" + } catch (e) { + return { isError: e instanceof Error, message: e.message } + } + `), + ) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ isError: true, message: "Refused" }) + }) + + test("propagates host interruption instead of returning a diagnostic", async () => { + const exit = await Effect.runPromiseExit( + CodeMode.make({ + tools: { + host: { + call: Tool.make({ + description: "Interrupt", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.interrupt, + }), + }, + }, + }).execute("return await tools.host.call({})"), + ) + + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) + } + }) +}) + +describe("CodeMode tool-call observation", () => { + test("reports the tools actually invoked with decoded input", async () => { + const calls: Array = [] + const lookup = Tool.make({ + description: "Look up a value", + input: Schema.Struct({ query: Schema.String }), + output: Schema.String, + run: ({ query }) => Effect.succeed(query), + }) + + const result = await Effect.runPromise( + CodeMode.make({ + tools: { context: { lookup } }, + onToolCallStart: (call) => Effect.sync(() => calls.push(call)), + }).execute(` + if (false) await tools.context.lookup({ query: "not called" }) + return await tools.context.lookup({ query: "deployment failure" }) + `), + ) + + expect(result.ok).toBe(true) + expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }]) + }) + + test("observes settled calls with outcome and duration", async () => { + const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = [] + const lookup = Tool.make({ + description: "Look up a value", + input: Schema.Struct({ query: Schema.String }), + output: Schema.String, + run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)), + }) + + const runtime = CodeMode.make({ + tools: { context: { lookup } }, + onToolCallStart: (call) => + Effect.sync(() => { + events.push({ phase: "start", index: call.index, name: call.name }) + }), + onToolCallEnd: (call) => + Effect.sync(() => { + expect(call.durationMs).toBeGreaterThanOrEqual(0) + events.push({ + phase: "end", + index: call.index, + name: call.name, + outcome: call.outcome, + ...(call.message === undefined ? {} : { message: call.message }), + }) + }), + }) + + const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`)) + expect(success.ok).toBe(true) + const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`)) + expect(failure.ok).toBe(false) + + expect(events).toStrictEqual([ + { phase: "start", index: 0, name: "context.lookup" }, + { phase: "end", index: 0, name: "context.lookup", outcome: "success" }, + { phase: "start", index: 0, name: "context.lookup" }, + { phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" }, + ]) + }) +}) + +describe("CodeMode console capture", () => { + test("captures console output as bounded result logs", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + const returned = console.log("Thread info:", { name: "Demo", count: 2 }) + console.warn("careful") + return returned + `, + }), + ) + + expect(result).toStrictEqual({ + ok: true, + value: null, + logs: ['Thread info: {"name":"Demo","count":2}', "[warn] careful"], + toolCalls: [], + }) + expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) + }) + + test("keeps logs captured before failures", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log("before failure") + throw new Error("boom") + `, + }), + ) + + expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"]) + expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom") + }) + + test("prints NaN and Infinity literally instead of the JSON null", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log(NaN) + console.log(Infinity, -Infinity) + console.log({ ratio: NaN, bounds: [Infinity] }) + return null + `, + }), + ) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}']) + }) + + test("renders sandbox values nested inside logged containers", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) }) + console.log([new Date(0)]) + return null + `, + }), + ) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual([ + '{"m":Map(1) [["a",1]],"when":1970-01-01T00:00:00.000Z,"r":/ab/g,"s":Set(2) [1,2]}', + "[1970-01-01T00:00:00.000Z]", + ]) + }) + + test("console formatting is total: cycles and opaque references render as markers", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + const m = new Map() + m.set("self", m) + console.log({ box: m }) + console.log({ fn: (x) => x, ok: 1 }) + return null + `, + }), + ) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}']) + }) + + test("console.table renders sandbox value cells", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.table([{ when: new Date(0), n: NaN }]) + return null + `, + }), + ) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"]) + }) + + test("captures console.dir and console.table output", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.dir({ nested: { ok: true } }) + console.table([ + { name: "Kit", count: 1, hidden: "x" }, + { name: "Olive", count: 2, hidden: "y" } + ], ["name", "count"]) + return "done" + `, + }), + ) + + expect(result).toStrictEqual({ + ok: true, + value: "done", + logs: ['{"nested":{"ok":true}}', "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2"], + toolCalls: [], + }) + }) +}) + +describe("CodeMode output budget", () => { + test("absent maxOutputBytes means no truncation at all", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`, + }), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.truncated).toBeUndefined() + expect(result.value).toBe("x".repeat(100_000)) + expect(result.logs).toStrictEqual(["z".repeat(50_000)]) + }) + + test("truncates an oversized result value with a marker instead of failing", async () => { + const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 } + const result = await Effect.runPromise( + CodeMode.execute({ + code: `return { data: "${"x".repeat(200)}" }`, + limits, + }), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.truncated).toBe(true) + expect(typeof result.value).toBe("string") + expect(result.value).toMatch( + /^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/, + ) + expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) + }) + + test("keeps leading logs within the remaining budget and marks the cut", async () => { + const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 } + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log("first line") + console.log("${"y".repeat(200)}") + return "ok" + `, + limits, + }), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("ok") + expect(result.truncated).toBe(true) + expect(result.logs).toStrictEqual(["first line", "[logs truncated: showing 1 of 2 lines]"]) + }) + + test("does not mark results within the budget", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log("fits") + return { fits: true } + `, + }), + ) + expect(result).toStrictEqual({ + ok: true, + value: { fits: true }, + logs: ["fits"], + toolCalls: [], + }) + }) +}) + +describe("CodeMode schema flexibility", () => { + test("accepts render-only JSON Schema input and omitted output", async () => { + const observed: Array = [] + const call = Tool.make({ + description: "Call an adapter-described tool", + input: { + type: "object", + properties: { id: { type: "string" }, count: { type: "number" } }, + required: ["id"], + }, + run: (input) => + Effect.sync(() => { + observed.push(input) + return { echoed: input } + }), + }) + const runtime = CodeMode.make({ tools: { adapter: { call } } }) + + expect(runtime.catalog()).toStrictEqual([ + { + path: "adapter.call", + description: "Call an adapter-described tool", + signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise", + }, + ]) + + // JSON Schema is render-only: mistyped input passes through unvalidated. + const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } }) + expect(observed).toStrictEqual([{ id: 42 }]) + }) + + test("renders JSON Schema outputs and $defs references", async () => { + const lookup = Tool.make({ + description: "Look up a user", + input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] }, + output: { + $ref: "#/$defs/User", + $defs: { + User: { + type: "object", + properties: { login: { type: "string" }, id: { type: "number" } }, + required: ["login", "id"], + }, + }, + }, + run: () => Effect.succeed({ login: "kit", id: 7 }), + }) + const runtime = CodeMode.make({ tools: { users: { lookup } } }) + + expect(runtime.catalog()).toStrictEqual([ + { + path: "users.lookup", + description: "Look up a user", + signature: "tools.users.lookup(input: {\n login: string,\n}): Promise<{\n login: string,\n id: number,\n}>", + }, + ]) + + const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 }) + }) + + test("Effect Schema output without an input transform still renders unknown when omitted", async () => { + const ping = Tool.make({ + description: "Ping", + input: Schema.Struct({ host: Schema.String }), + run: () => Effect.succeed("pong"), + }) + const runtime = CodeMode.make({ tools: { net: { ping } } }) + expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise") + + const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toBe("pong") + }) +}) + +describe("CodeMode public contract", () => { + const lookup = Tool.make({ + description: "Look up an order by ID", + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ id: Schema.String, status: Schema.String }), + run: ({ id }) => Effect.succeed({ id, status: "open" }), + }) + const tools = { orders: { lookup } } + const source = `return await tools.orders.lookup({ id: "order_42" })` + + test("keeps one-shot and reusable execution equivalent", async () => { + const runtime = CodeMode.make({ tools }) + const [oneShot, reusable] = await Promise.all([ + Effect.runPromise(CodeMode.execute({ tools, code: source })), + Effect.runPromise(runtime.execute(source)), + ]) + + expect(reusable).toStrictEqual(oneShot) + const input: CodeMode.Input = { code: source } + expect(Schema.decodeUnknownSync(CodeMode.Input)(input)).toStrictEqual(input) + expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable) + }) + + test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => { + const runtime = CodeMode.make({ tools }) + expect(runtime.catalog()).toStrictEqual([ + { + path: "orders.lookup", + description: "Look up an order by ID", + signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", + }, + ]) + expect(runtime.instructions()).toContain("Available tools (COMPLETE list") + expect(runtime.instructions()).toContain("- orders (1 tool)") + expect(runtime.instructions()).toContain( + " - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID", + ) + // A fully inlined catalog does not advertise search in the instructions... + expect(runtime.instructions()).not.toMatch(/\$codemode/) + + // ...but the search tool stays registered, so a speculative call still works with the + // same signature as the inline catalog. + const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toStrictEqual({ + items: [ + { + path: "tools.orders.lookup", + description: "Look up an order by ID", + signature: + "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", + }, + ], + remaining: 0, + next: null, + }) + } + }) + + test("renders bracket notation for tool names that are not JavaScript identifiers", async () => { + const resolveLibrary = Tool.make({ + description: "Resolve a library ID", + input: Schema.Struct({ libraryName: Schema.String }), + output: Schema.String, + run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`), + }) + const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) + + expect(runtime.catalog()).toStrictEqual([ + { + path: "context7.resolve-library-id", + description: "Resolve a library ID", + signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', + }, + ]) + expect(runtime.instructions()).toContain( + 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', + ) + + const search = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`), + ) + expect(search.ok).toBe(true) + if (search.ok) { + expect(search.value).toStrictEqual({ + items: [ + { + path: 'tools.context7["resolve-library-id"]', + description: "Resolve a library ID", + signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', + }, + ], + remaining: 0, + next: null, + }) + } + + const call = await Effect.runPromise( + runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`), + ) + expect(call.ok).toBe(true) + if (call.ok) expect(call.value).toBe("/resolved/TypeScript") + + const exact = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`), + ) + expect(exact.ok).toBe(true) + if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null }) + }) + + test("instructions use markdown sections with placeholder-only call forms", () => { + const runtime = CodeMode.make({ tools }) + const instructions = runtime.instructions() + // Sections in order: workflow at the top, catalog at the bottom. + expect(instructions).toContain("## Workflow") + expect(instructions).toContain("## Rules") + expect(instructions).toContain("## Language") + expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules")) + expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Language")) + expect(instructions.indexOf("## Language")).toBeLessThan( + instructions.indexOf("\n## Available tools (COMPLETE list"), + ) + expect(instructions).not.toContain("JSON.parse(res)") + expect(instructions).toContain("Return only the fields you need") + expect(instructions).toContain("avoid returning large raw payloads") + expect(instructions).toContain("Do not infer or normalize tool names") + expect(instructions).toContain("bracket notation and quotes are part of the path") + expect(instructions).toContain("surrounding agent tools are not available") + expect(instructions).toContain("Only Code Mode tools listed here and internal runtime tools") + // Placeholders use generic namespace/tool/field names only - no fabricated real tools + // and no real catalog tools cherry-picked into example lines. + expect(instructions).toContain("`const result = await tools..(input)`") + expect(instructions).toContain("Return only the fields you need from structured results") + expect(instructions).toContain("check that it is a non-null object and not an array") + expect(instructions).not.toContain("result.") + expect(instructions).not.toContain("data.") + expect(instructions).not.toContain("total_count") + expect(instructions).not.toContain("list_issues") + expect(instructions).not.toContain("tools.orders.lookup({") + // COMPLETE: step 1 picks from the inlined list; search is not advertised. + expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`") + expect(instructions).not.toContain("Browse one namespace") + + const partial = CodeMode.make({ tools, discovery: { catalogBudget: 0 } }).instructions() + // PARTIAL: the workflow starts with search (with query-style guidance that is clearly + // a query string, never a tool name) and the browse-namespace rule appears. + expect(partial).toContain( + '1. If needed, discover tools: `return await tools.$codemode.search({ query: "" })`.', + ) + expect(partial).toContain("In the next execution, copy a returned path exactly") + expect(partial).toContain( + "Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools", + ) + expect(partial).toContain( + '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', + ) + expect(partial).toContain("repeat the same search with `offset: next.offset`") + expect(partial).toContain(" limit?: number,\n offset?: number,") + expect(partial).not.toContain("total_count") + expect(partial).not.toContain("tools.orders.lookup({") + }) + + test("the language section describes the restricted runtime without overclaiming", () => { + const instructions = CodeMode.make({ tools }).instructions() + expect(instructions).toContain("restricted JavaScript language for calling tools") + expect(instructions).toContain("not a general-purpose runtime") + expect(instructions).not.toContain("Standard modern JavaScript works") + expect(instructions).not.toContain("TypeScript type annotations") + for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) { + expect(instructions).toContain(missing) + } + expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") + expect(instructions).not.toContain("host globals") + expect(instructions).toContain("Use Code Mode tools for external operations") + expect(instructions).toContain( + "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", + ) + }) + + test("zero tools keep minimal sections and the no-tools notice", () => { + const runtime = CodeMode.make({}) + const instructions = runtime.instructions() + expect(instructions).toContain("No tools are currently available.") + expect(instructions).toContain("## Language") + expect(instructions).toContain("## Available tools") + expect(instructions).not.toContain("## Workflow") + expect(instructions).not.toContain("## Rules") + expect(instructions).not.toMatch(/\$codemode/) + }) + + test("uses one ranked search returning complete definitions for large catalogs", async () => { + const upload = Tool.make({ + description: "Upload one readable local file to the current Discord thread", + input: Schema.Struct({ path: Schema.String }), + output: Schema.Struct({ sent: Schema.Boolean }), + run: () => Effect.succeed({ sent: true }), + }) + const generate = Tool.make({ + description: "Generate an image and upload it to the current Discord thread", + input: Schema.Struct({ prompt: Schema.String }), + output: Schema.Struct({ sent: Schema.Boolean }), + run: () => Effect.succeed({ sent: true }), + }) + const runtime = CodeMode.make({ + tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, + discovery: { catalogBudget: 0 }, + }) + expect(runtime.instructions()).toContain( + "Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)", + ) + expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") + expect(runtime.instructions()).toContain("- orders (1 tool, none shown)") + expect(runtime.instructions()).toMatch(/\$codemode\.search/) + expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/) + + const result = await Effect.runPromise( + runtime.execute(` + return await tools.$codemode.search({ + query: "send message attachment upload file to current Discord thread", + limit: 2 + }) + `), + ) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toStrictEqual({ + items: [ + { + path: "tools.thread.uploadFile", + description: "Upload one readable local file to the current Discord thread", + signature: "tools.thread.uploadFile(input: {\n path: string,\n}): Promise<{\n sent: boolean,\n}>", + }, + { + path: "tools.thread.generateImage", + description: "Generate an image and upload it to the current Discord thread", + signature: "tools.thread.generateImage(input: {\n prompt: string,\n}): Promise<{\n sent: boolean,\n}>", + }, + ], + remaining: 0, + next: null, + }) + expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }]) + + const variants = await Effect.runPromise( + runtime.execute(` + return await Promise.all([ + tools.$codemode.search({ query: "file" }), + tools.$codemode.search({ query: "image" }) + ]) + `), + ) + expect(variants.ok).toBe(true) + if (variants.ok) { + expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe( + "tools.thread.uploadFile", + ) + expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe( + "tools.thread.generateImage", + ) + } + + const removed = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`), + ) + expect(removed.ok).toBe(false) + if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool") + }) + + test("search defaults to 10 results and resolves exact tool paths", async () => { + const tool = (index: number) => + Tool.make({ + description: `Numbered tool ${index}`, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + many: Object.fromEntries(Array.from({ length: 14 }, (_, index) => [`tool${index}`, tool(index)])), + }, + }) + + const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { + items: Array<{ path: string }> + remaining: number + next: { offset: number } | null + } + expect(value.items).toHaveLength(10) + expect(value.remaining).toBe(4) + expect(value.next).toStrictEqual({ offset: 10 }) + } + + for (const query of ["many.tool13", "tools.many.tool13"]) { + const exact = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), + ) + expect(exact.ok).toBe(true) + if (exact.ok) { + expect(exact.value).toStrictEqual({ + items: [ + { + path: "tools.many.tool13", + description: "Numbered tool 13", + signature: "tools.many.tool13(input: {\n id: string,\n}): Promise", + }, + ], + remaining: 0, + next: null, + }) + } + } + }) + + test("scopes search to one namespace and browses it alphabetically", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + github: { list_issues: simple("List issues"), create_issue: simple("Create an issue") }, + linear: { list_issues: simple("List Linear issues") }, + }, + }) + + // Empty query + namespace browses just that namespace, alphabetical by path. + const browse = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`), + ) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.github.create_issue", + "tools.github.list_issues", + ]) + } + + // A query + namespace ranks within that namespace only. + const scoped = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`), + ) + expect(scoped.ok).toBe(true) + if (scoped.ok) { + const value = scoped.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) + expect(value.items[0]?.path).toBe("tools.linear.list_issues") + } + + const invalid = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`), + ) + expect(invalid.ok).toBe(false) + if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput") + }) + + test("matches input parameter names and partial-word substrings", async () => { + const upload = Tool.make({ + description: "Send a document to the workspace", + input: { + type: "object", + properties: { attachment: { type: "string", description: "Local path of the payload to send" } }, + required: ["attachment"], + }, + run: () => Effect.succeed("ok"), + }) + const other = Tool.make({ + description: "Rename the workspace", + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ tools: { files: { upload, other } } }) + + // "attachment" appears in neither path nor description - only in the input schema's + // property names, which the searchable text includes. + const byParameter = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`), + ) + expect(byParameter.ok).toBe(true) + if (byParameter.ok) { + const value = byParameter.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) + expect(value.items[0]?.path).toBe("tools.files.upload") + } + + // Substring matching: a partial word ("docum") still hits the description. + const bySubstring = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "docum" })`), + ) + expect(bySubstring.ok).toBe(true) + if (bySubstring.ok) { + const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) + expect(value.items[0]?.path).toBe("tools.files.upload") + } + }) + + test("a plural query term matches singular-only tool text", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + // Neither path nor description contains "issues" - only the singular "issue". + tracker: { fetch_all: simple("Fetch every open issue in the project") }, + github: { list_issues: simple("List issues") }, + misc: { rename: simple("Rename the workspace") }, + }, + }) + + // "issues" still finds the singular-only tool (term OR singular(term) per field)... + const plural = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`), + ) + expect(plural.ok).toBe(true) + if (plural.ok) { + const value = plural.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) + expect(value.items[0]?.path).toBe("tools.tracker.fetch_all") + } + + // ...while a true "issues" path match still outranks the singular-only description match. + const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`)) + expect(ranked.ok).toBe(true) + if (ranked.ok) { + const value = ranked.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.github.list_issues", + "tools.tracker.fetch_all", + ]) + } + }) + + test("empty query lists everything alphabetically by path", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + // Deliberately declared out of alphabetical order. + const runtime = CodeMode.make({ + tools: { + zeta: { last: simple("Last") }, + alpha: { beta: simple("Middle"), aardvark: simple("First") }, + }, + }) + const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown } + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.alpha.aardvark", + "tools.alpha.beta", + "tools.zeta.last", + ]) + expect(value.remaining).toBe(0) + expect(value.next).toBeNull() + } + + const middle = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 1 })`), + ) + expect(middle.ok).toBe(true) + if (middle.ok) { + expect(middle.value).toMatchObject({ + items: [{ path: "tools.alpha.beta" }], + remaining: 1, + next: { offset: 2 }, + }) + } + + const exhausted = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 3 })`), + ) + expect(exhausted.ok).toBe(true) + if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null }) + }) + + test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => { + const cheap = Tool.make({ + description: "Cheap", + input: Schema.Struct({ q: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const expensive = Tool.make({ + description: + "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime", + input: Schema.Struct({ + someRatherLongParameterName: Schema.String, + anotherEvenLongerParameterName: Schema.Number, + }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + // Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2 + // alpha.expensive does not fit, which marks only alpha done - it must NOT prevent + // other namespaces from inlining (beta already got its line in the same round). + const runtime = CodeMode.make({ + tools: { alpha: { cheap, expensive }, beta: { cheap } }, + discovery: { catalogBudget: 40 }, + }) + + const instructions = runtime.instructions() + expect(instructions).toContain( + "Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)", + ) + expect(instructions).toContain("- alpha (2 tools, 1 shown)") + expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise // Cheap") + expect(instructions).not.toContain("tools.alpha.expensive(") + // Fully shown namespaces read cleanly (no "shown" annotation). + expect(instructions).toContain("- beta (1 tool)") + expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise // Cheap") + expect(instructions).toMatch(/\$codemode\.search/) + }) + + test("charges inline JSDoc against the catalog token budget", () => { + const documented = Tool.make({ + description: "Look up a record", + input: { + type: "object", + properties: { + id: { type: "string", description: "A detailed identifier description. ".repeat(20) }, + }, + required: ["id"], + } as const, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { records: { lookup: documented } }, + discovery: { catalogBudget: 40 }, + }) + + expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.") + expect(runtime.instructions()).toContain( + "Available tools (PARTIAL - 0 of 1 shown; find the rest with tools.$codemode.search)", + ) + expect(runtime.instructions()).not.toContain("tools.records.lookup(input:") + }) + + test("decodes tool input and output before exposing either side", async () => { + const observed: Array = [] + const transformed = Tool.make({ + description: "Double a number", + input: Schema.Struct({ value: Schema.NumberFromString }), + output: Schema.NumberFromString, + run: ({ value }) => + Effect.sync(() => { + observed.push(value) + return String(value * 2) + }), + }) + const runtime = CodeMode.make({ + tools: { math: { double: transformed } }, + onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)), + }) + + const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`)) + expect(success).toStrictEqual({ ok: true, value: 42, toolCalls: [{ name: "math.double" }] }) + expect(observed).toStrictEqual([{ value: 21 }, 21]) + + const invalid = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: 21 })`)) + expect(invalid.ok).toBe(false) + if (invalid.ok) return + expect(invalid.error.kind).toBe("InvalidToolInput") + expect(observed).toStrictEqual([{ value: 21 }, 21]) + }) + + test("returns JSON-safe data and normalizes undefined to null", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: `return { top: undefined, nested: [1, undefined] }`, + }), + ) + expect(result).toStrictEqual({ + ok: true, + value: { top: null, nested: [1, null] }, + toolCalls: [], + }) + expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) + }) + + test("rejects invalid configuration and discovery limits", async () => { + expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( + RangeError, + ) + expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) + + expect(() => CodeMode.make({ tools, discovery: { catalogBudget: -1 } })).toThrow(RangeError) + + const result = await Effect.runPromise( + CodeMode.make({ + tools, + discovery: { catalogBudget: 0 }, + }).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`), + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("InvalidToolInput") + + for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) { + const invalidOffset = await Effect.runPromise( + CodeMode.make({ tools }).execute( + `return await tools.$codemode.search({ query: "order", offset: ${JSON.stringify(offset)} })`, + ), + ) + expect(invalidOffset.ok).toBe(false) + if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput") + } + }) + + test("enforces the tool-call limit as a diagnostic", async () => { + const result = await Effect.runPromise(CodeMode.execute({ tools, code: source, limits: { maxToolCalls: 0 } })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded") + }) + + test("timeoutMs and maxToolCalls have no defaults: absent means unlimited", async () => { + // 150 tool calls would have exceeded the old default cap of 100; with no limits + // provided, there is no cap and no timeout - budgets are host policy. + const counter = Tool.make({ + description: "Count invocations", + input: Schema.Struct({}), + output: Schema.Number, + run: () => Effect.succeed(1), + }) + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { count: counter } }, + code: ` + let total = 0 + for (let i = 0; i < 150; i += 1) total += await tools.host.count({}) + return total + `, + }), + ) + expect(result).toMatchObject({ ok: true, value: 150 }) + if (result.ok) expect(result.toolCalls.length).toBe(150) + }) + + test("the timeout interrupts a busy loop without any operation budget", async () => { + // Regression: timeout interruption must not depend on interpreter-side work accounting. + // The Effect fiber runtime auto-yields between interpreter steps, so a pure `while + // (true) {}` loop is interrupted by `timeoutMs` alone. + const startedAt = Date.now() + const result = await Effect.runPromise(CodeMode.execute({ code: "while (true) {}", limits: { timeoutMs: 200 } })) + const elapsedMs = Date.now() - startedAt + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.kind).toBe("TimeoutExceeded") + expect(result.error.message).toContain("timed out after 200ms") + } + expect(elapsedMs).toBeLessThan(3_000) + }) + + test("reserves the discovery namespace", () => { + expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/) + }) +}) diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0de3dc3ea0ddd7e5b415159163c33e4306ecbca2 --- /dev/null +++ b/packages/codemode/test/enumeration.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" + +// Key enumeration: Object.keys and for...in share one surface over plain objects, arrays +// (index strings), and tool references (namespace/tool names from the host tool tree), so a +// model can discover what it may call instead of guessing names from the instructions. The +// motivating transcript: `Object.keys(tools)` failed with the generic plain-objects-only +// message and `for (const key in tools)` was unsupported syntax, forcing blind guesses. + +const echo = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ value: Schema.String }), + output: Schema.String, + run: ({ value }) => Effect.succeed(value), + }) + +const tools = { + github: { list_issues: echo("List issues"), get_issue: echo("Get one issue") }, + memory: { search: echo("Search memory") }, + playwright: { navigate: echo("Navigate somewhere") }, +} + +const run = (code: string) => Effect.runPromise(CodeMode.execute({ tools, code })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("Object.keys over tool references", () => { + test("enumerates top-level namespaces (the transcript program)", async () => { + expect( + await value(` + const namespaces = Object.keys(tools) + return { namespaces, count: namespaces.length } + `), + ).toEqual({ namespaces: ["github", "memory", "playwright", "$codemode"], count: 4 }) + }) + + test("enumerates tool names at a nested namespace", async () => { + expect(await value(`return Object.keys(tools.github)`)).toEqual(["list_issues", "get_issue"]) + }) + + test("a callable tool is a leaf and enumerates as []", async () => { + expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([]) + }) + + test("the internal discovery namespace enumerates its callable surface", async () => { + expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"]) + }) + + test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => { + const failure = await error(`return Object.keys(tools.nonexistent)`) + expect(failure.kind).toBe("UnknownTool") + expect(failure.message).toContain("Unknown tool namespace 'nonexistent'") + expect(failure.suggestions?.join(" ")).toContain("Object.keys(tools)") + }) + + test("Object.values/entries on a tool reference explain the working idioms", async () => { + for (const method of ["values", "entries"] as const) { + const failure = await error(`return Object.${method}(tools)`) + expect(failure.kind).toBe("InvalidDataValue") + expect(failure.message).toContain( + `Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + ) + } + const nested = await error(`return Object.entries(tools.github)`) + expect(nested.message).toContain("Use Object.keys(tools) for names") + }) +}) + +describe("Object.keys over arrays", () => { + test("returns index strings, like JS", async () => { + expect(await value(`return Object.keys(["a", "b", "c"])`)).toEqual(["0", "1", "2"]) + expect(await value(`return Object.keys([])`)).toEqual([]) + }) + + test("objects keep their own enumerable keys", async () => { + expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"]) + }) + + test("non-object inputs still fail clearly", async () => { + const failure = await error(`return Object.keys("nope")`) + expect(failure.message).toContain("Object.keys expects a data object or array") + }) +}) + +describe("for...in", () => { + test("iterates own enumerable keys of a plain object with break/continue", async () => { + expect( + await value(` + const seen = [] + for (const key in { a: 1, b: 2, c: 3, d: 4 }) { + if (key === "b") continue + if (key === "d") break + seen.push(key) + } + return seen + `), + ).toEqual(["a", "c"]) + }) + + test("iterates index strings over arrays", async () => { + expect( + await value(` + const indexes = [] + for (const i in ["x", "y", "z"]) { + if (i === "2") break + indexes.push(i) + } + return indexes + `), + ).toEqual(["0", "1"]) + }) + + test("supports let declarations and bare identifiers", async () => { + expect( + await value(` + let last = "" + for (let key in { a: 1, b: 2 }) last = key + return last + `), + ).toBe("b") + expect( + await value(` + let key = "before" + for (key in { only: 1 }) {} + return key + `), + ).toBe("only") + }) + + test("enumerates namespaces and tools from the callable tool tree", async () => { + expect( + await value(` + const names = [] + for (const ns in tools) { + for (const name in tools[ns]) names.push(ns + "." + name) + } + return names + `), + ).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate", "$codemode.search"]) + }) + + test("unsupported values fail with a hint at for...of and Object.keys", async () => { + for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) { + const failure = await error(`for (const key in ${expression}) {}; return "no"`) + expect(failure.message).toContain("for...in requires a plain object, array, or tools reference") + expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)") + } + }) +}) diff --git a/packages/codemode/test/fixtures/openapi-happy-path.json b/packages/codemode/test/fixtures/openapi-happy-path.json new file mode 100644 index 0000000000000000000000000000000000000000..8052dd739113cd9001748c83832df2fab22d7a97 --- /dev/null +++ b/packages/codemode/test/fixtures/openapi-happy-path.json @@ -0,0 +1,230 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "CodeMode Happy Path", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.example.test/v1" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "paths": { + "/users/{userId}": { + "parameters": [ + { + "$ref": "#/components/parameters/UserId" + } + ], + "get": { + "operationId": "users.get", + "summary": "Get a user", + "parameters": [ + { + "name": "include", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "verbose", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "X-Trace-ID", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/UserResponse" + } + } + }, + "delete": { + "operationId": "users.remove", + "summary": "Remove a user", + "responses": { + "204": { + "description": "Removed" + } + } + } + }, + "/users": { + "post": { + "operationId": "users.create", + "summary": "Create a user", + "security": [ + { + "ApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "role": { + "type": "string", + "enum": ["admin", "member"] + } + }, + "required": ["name", "email"], + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/vnd.example+json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/search": { + "get": { + "operationId": "search.run", + "summary": "Search users", + "security": [], + "parameters": [ + { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "page": { + "type": "integer" + } + }, + "required": ["query"], + "additionalProperties": false + } + }, + { + "name": "tags", + "in": "query", + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "Summary", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "parameters": { + "UserId": { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + }, + "responses": { + "UserResponse": { + "description": "A user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "role": { + "type": "string", + "enum": ["admin", "member"] + } + }, + "required": ["id", "name", "email"], + "additionalProperties": false + } + }, + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer" + }, + "ApiKey": { + "type": "apiKey", + "in": "query", + "name": "api_key" + } + } + } +} diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json new file mode 100644 index 0000000000000000000000000000000000000000..c78194e669fd5a1e3c2d49cfe521e16675419fa9 --- /dev/null +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -0,0 +1,23730 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "opencode HttpApi", + "version": "0.0.1", + "description": "Experimental HttpApi surface for selected instance routes." + }, + "paths": { + "/api/health": { + "get": { + "tags": ["server.health"], + "operationId": "v2.health.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["healthy"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Check whether the API server is ready to accept requests.", + "summary": "Check server health" + } + }, + "/api/location": { + "get": { + "tags": ["server.location"], + "operationId": "v2.location.get", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Location.Info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Location.Info" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Resolve the requested location or the server default location.", + "summary": "Get location" + } + }, + "/api/agent": { + "get": { + "tags": ["server.agent"], + "operationId": "v2.agent.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentV2.Info" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered agents.", + "summary": "List agents" + } + }, + "/api/plugin": { + "get": { + "tags": ["plugins"], + "operationId": "v2.plugin.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plugin.Info" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently loaded plugins.", + "summary": "List plugins" + } + }, + "/api/session": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.list", + "parameters": [ + { + "name": "workspace", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Maximum number of sessions to return. Defaults to the newest 50 sessions." + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["asc", "desc"] + }, + { + "type": "null" + } + ], + "description": "Session order for the first page. Use desc for newest first or asc for oldest first." + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "parentID", + "in": "query", + "schema": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "string", + "enum": ["null"] + } + ], + "description": "Filter by parent session. Use null to return only root sessions." + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "directory", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "project", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "subpath", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response." + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionsResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsResponse" + } + } + } + }, + "400": { + "description": "InvalidCursorError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidCursorError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + "summary": "List sessions" + }, + "post": { + "tags": ["sessions"], + "operationId": "v2.session.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Create a session at the requested location.", + "summary": "Create session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/Location.Ref" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/active": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.active", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "patternProperties": { + "^ses": { + "$ref": "#/components/schemas/SessionActive" + } + } + }, + "watermarks": { + "$ref": "#/components/schemas/SessionWatermarks" + } + }, + "required": ["data", "watermarks"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "summary": "List active sessions" + } + }, + "/api/session/{sessionID}": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a session by ID.", + "summary": "Get session" + } + }, + "/api/session/{sessionID}/fork": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.fork", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "summary": "Fork session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/agent": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchAgent", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the agent used by subsequent provider turns.", + "summary": "Switch session agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + } + }, + "required": ["agent"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/model": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchModel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the model used by subsequent provider turns.", + "summary": "Switch session model", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": ["model"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/rename": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.rename", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Update the session title.", + "summary": "Rename session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + } + }, + "required": ["title"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/prompt": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.prompt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Admitted" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + } + }, + "description": "Durably admit one session input and schedule agent-loop execution unless resume is false.", + "summary": "Send message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/PromptInput" + }, + "delivery": { + "anyOf": [ + { + "type": "string", + "enum": ["steer", "queue"] + }, + { + "type": "null" + } + ] + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prompt"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/command": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.command", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Admitted" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | CommandNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CommandNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + }, + "500": { + "description": "CommandEvaluationError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandEvaluationError" + } + } + } + } + }, + "description": "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.", + "summary": "Run command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "command": { + "type": "string" + }, + "arguments": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptInput.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + }, + "delivery": { + "anyOf": [ + { + "type": "string", + "enum": ["steer", "queue"] + }, + { + "type": "null" + } + ] + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/skill": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.skill", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | SkillNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Activate a skill for a session by appending a skill message and resuming execution.", + "summary": "Activate skill", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "skill": { + "type": "string" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": ["skill"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/synthetic": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.synthetic", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Append a synthetic message to a session and resume execution.", + "summary": "Add synthetic message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "type": "object" + } + }, + "required": ["text"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/compact": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.compact", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Compact a session conversation.", + "summary": "Compact session" + } + }, + "/api/session/{sessionID}/wait": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.wait", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Wait for a session agent loop to become idle.", + "summary": "Wait for session" + } + }, + "/api/session/{sessionID}/revert/stage": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.stage", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "MessageNotFoundError | SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Stage or move a reversible session boundary and optionally apply its file changes.", + "summary": "Stage session revert", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "files": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": ["messageID"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/revert/clear": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.clear", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "summary": "Clear staged revert" + } + }, + "/api/session/{sessionID}/revert/commit": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.commit", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + } + }, + "summary": "Commit staged revert" + } + }, + "/api/session/{sessionID}/context": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.context", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Message" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Retrieve the active context messages for a session (all messages after the last compaction).", + "summary": "Get session context" + } + }, + "/api/session/{sessionID}/context-entry": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.context.entry.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionContextEntry.Info" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "List API-managed context entries attached to the session's system context.", + "summary": "List context entries" + } + }, + "/api/session/{sessionID}/context-entry/{key}": { + "put": { + "tags": ["sessions"], + "operationId": "v2.session.context.entry.put", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "key", + "in": "path", + "schema": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.", + "summary": "Put context entry", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": {} + }, + "required": ["value"], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": ["sessions"], + "operationId": "v2.session.context.entry.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "key", + "in": "path", + "schema": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Remove one context entry; the removal is announced to the model at the next turn boundary.", + "summary": "Remove context entry" + } + }, + "/api/session/{sessionID}/log": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.log", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "after", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "follow", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["true", "false"] + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SessionLogItemStream" + } + }, + "required": ["id", "event", "data"], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Fail"] + }, + "error": { + "not": {} + } + }, + "required": ["_tag", "error"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Die"] + }, + "defect": {} + }, + "required": ["_tag", "defect"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Interrupt"] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "fiberId"], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "summary": "Read the session log" + } + }, + "/api/session/{sessionID}/interrupt": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.interrupt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + "summary": "Interrupt session execution" + } + }, + "/api/session/{sessionID}/background": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.background", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.", + "summary": "Background blocking session tools" + } + }, + "/api/session/{sessionID}/message/{messageID}": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.message", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Session.Message" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve one projected message owned by the Session.", + "summary": "Get session message" + } + }, + "/api/session/{sessionID}/message": { + "get": { + "tags": ["messages"], + "operationId": "v2.session.messages", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Maximum number of messages to return. When omitted, the endpoint returns its default page size." + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["asc", "desc"] + }, + { + "type": "null" + } + ], + "description": "Message order for the first page. Use desc for newest first or asc for oldest first." + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionMessagesResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMessagesResponse" + } + } + } + }, + "400": { + "description": "InvalidCursorError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidCursorError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "summary": "Get session messages" + } + }, + "/api/model": { + "get": { + "tags": ["models"], + "operationId": "v2.model.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelV2.Info" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve available models ordered by release date.", + "summary": "List models" + } + }, + "/api/model/default": { + "get": { + "tags": ["models"], + "operationId": "v2.model.default", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelV2.Info" + }, + { + "type": "null" + } + ] + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve the model used when a session has no explicit model selection.", + "summary": "Get default model" + } + }, + "/api/generate": { + "post": { + "tags": ["generate"], + "operationId": "v2.generate.text", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "GenerateTextResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateTextResponse" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified.", + "summary": "Generate text", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prompt"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/provider": { + "get": { + "tags": ["providers"], + "operationId": "v2.provider.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderV2.Info" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve active AI providers so clients can show provider availability and configuration.", + "summary": "List providers" + } + }, + "/api/provider/{providerID}": { + "get": { + "tags": ["providers"], + "operationId": "v2.provider.get", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/ProviderV2.Info" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ProviderNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderNotFoundError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.", + "summary": "Get provider" + } + }, + "/api/integration": { + "get": { + "tags": ["integrations"], + "operationId": "v2.integration.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Integration.Info" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve available integrations and their authentication methods.", + "summary": "List integrations" + } + }, + "/api/integration/{integrationID}": { + "get": { + "tags": ["integrations"], + "operationId": "v2.integration.get", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.Info" + }, + { + "type": "null" + } + ] + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve one integration and its authentication methods.", + "summary": "Get integration" + } + }, + "/api/integration/{integrationID}/connect/key": { + "post": { + "tags": ["integrations"], + "operationId": "v2.integration.connect.key", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Run a key authentication method and store the resulting credential.", + "summary": "Connect with key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["key"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/integration/{integrationID}/connect/oauth": { + "post": { + "tags": ["integrations"], + "operationId": "v2.integration.connect.oauth", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Integration.Attempt" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Start an OAuth attempt and return the authorization details.", + "summary": "Begin OAuth connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "methodID": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["methodID", "inputs"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/integration/attempt/{attemptID}": { + "get": { + "tags": ["integrations"], + "operationId": "v2.integration.attempt.status", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Integration.AttemptStatus" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Poll the current status of an OAuth attempt.", + "summary": "Get OAuth attempt status" + }, + "delete": { + "tags": ["integrations"], + "operationId": "v2.integration.attempt.cancel", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Cancel an OAuth attempt and release its resources.", + "summary": "Cancel OAuth connection" + } + }, + "/api/integration/attempt/{attemptID}/complete": { + "post": { + "tags": ["integrations"], + "operationId": "v2.integration.attempt.complete", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Complete a code-based OAuth attempt and store the resulting credential.", + "summary": "Complete OAuth connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/mcp": { + "get": { + "tags": ["mcp"], + "operationId": "v2.mcp.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Server" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve configured MCP servers and their connection status.", + "summary": "List MCP servers" + } + }, + "/api/credential/{credentialID}": { + "patch": { + "tags": ["server.credential"], + "operationId": "v2.credential.update", + "parameters": [ + { + "name": "credentialID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Update a stored credential label.", + "summary": "Update credential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": ["label"], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": ["server.credential"], + "operationId": "v2.credential.remove", + "parameters": [ + { + "name": "credentialID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Remove a stored integration credential.", + "summary": "Remove credential" + } + }, + "/api/project/current": { + "get": { + "tags": ["projects"], + "operationId": "v2.project.current", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project.Current", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.Current" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Resolve the project for the requested location.", + "summary": "Get current project" + } + }, + "/api/project/{projectID}/directories": { + "get": { + "tags": ["projects"], + "operationId": "v2.project.directories", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project.Directories", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.Directories" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List known local absolute directories for a project.", + "summary": "List project directories" + } + }, + "/api/form/request": { + "get": { + "tags": ["forms"], + "operationId": "v2.form.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending forms for a location.", + "summary": "List pending form requests" + } + }, + "/api/session/{sessionID}/form": { + "get": { + "tags": ["forms"], + "operationId": "v2.session.form.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending forms for a session.", + "summary": "List session forms" + }, + "post": { + "tags": ["forms"], + "operationId": "v2.session.form.create", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + } + }, + "description": "Create a form for a session.", + "summary": "Create session form", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Form.CreatePayload" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/form/{formID}": { + "get": { + "tags": ["forms"], + "operationId": "v2.session.form.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a form for a session.", + "summary": "Get session form" + } + }, + "/api/session/{sessionID}/form/{formID}/state": { + "get": { + "tags": ["forms"], + "operationId": "v2.session.form.state", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Form.State" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve the current state for a form.", + "summary": "Get form state" + } + }, + "/api/session/{sessionID}/form/{formID}/reply": { + "post": { + "tags": ["forms"], + "operationId": "v2.session.form.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "FormInvalidAnswerError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormInvalidAnswerError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "FormAlreadySettledError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormAlreadySettledError" + } + } + } + } + }, + "description": "Submit an answer to a pending form.", + "summary": "Reply to form", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Form.Reply" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/form/{formID}/cancel": { + "post": { + "tags": ["forms"], + "operationId": "v2.session.form.cancel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "FormAlreadySettledError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormAlreadySettledError" + } + } + } + } + }, + "description": "Cancel a pending form.", + "summary": "Cancel form" + } + }, + "/api/permission/request": { + "get": { + "tags": ["permissions"], + "operationId": "v2.permission.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending permission requests for a location.", + "summary": "List pending permission requests" + } + }, + "/api/permission/saved": { + "get": { + "tags": ["permissions"], + "operationId": "v2.permission.saved.list", + "parameters": [ + { + "name": "projectID", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionSaved.Info" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve saved permissions, optionally filtered by project.", + "summary": "List saved permissions" + } + }, + "/api/permission/saved/{id}": { + "delete": { + "tags": ["permissions"], + "operationId": "v2.permission.saved.remove", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Remove a saved permission by ID.", + "summary": "Remove saved permission" + } + }, + "/api/session/{sessionID}/permission": { + "post": { + "tags": ["permissions"], + "operationId": "v2.session.permission.create", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "effect": { + "$ref": "#/components/schemas/PermissionV2.Effect" + } + }, + "required": ["id", "effect"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Evaluate and, when approval is required, create a permission request for a session.", + "summary": "Create permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + { + "type": "null" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["action", "resources"], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "get": { + "tags": ["permissions"], + "operationId": "v2.session.permission.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending permission requests owned by a session.", + "summary": "List session permission requests" + } + }, + "/api/session/{sessionID}/permission/{requestID}": { + "get": { + "tags": ["permissions"], + "operationId": "v2.session.permission.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a pending permission request owned by a session.", + "summary": "Get permission request" + } + }, + "/api/session/{sessionID}/permission/{requestID}/reply": { + "post": { + "tags": ["permissions"], + "operationId": "v2.session.permission.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Respond to a pending permission request owned by a session.", + "summary": "Reply to pending permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "$ref": "#/components/schemas/PermissionV2.Reply" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["reply"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/fs/read/*": { + "get": { + "tags": ["filesystem"], + "operationId": "v2.fs.read", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Serve one file relative to the requested location.", + "summary": "Read file" + } + }, + "/api/fs/list": { + "get": { + "tags": ["filesystem"], + "operationId": "v2.fs.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "path", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystem.Entry" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List direct children of one directory relative to the requested location.", + "summary": "List directory" + } + }, + "/api/fs/find": { + "get": { + "tags": ["filesystem"], + "operationId": "v2.fs.find", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": ["file", "directory"] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystem.Entry" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Find recursively ranked filesystem entries relative to the requested location.", + "summary": "Find files" + } + }, + "/api/command": { + "get": { + "tags": ["commands"], + "operationId": "v2.command.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommandV2.Info" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered commands.", + "summary": "List commands" + } + }, + "/api/skill": { + "get": { + "tags": ["skills"], + "operationId": "v2.skill.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillV2.Info" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered skills.", + "summary": "List skills" + } + }, + "/api/event": { + "get": { + "tags": ["events"], + "operationId": "v2.event.subscribe", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/V2EventStream" + } + }, + "required": ["id", "event", "data"], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Fail"] + }, + "error": { + "not": {} + } + }, + "required": ["_tag", "error"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Die"] + }, + "defect": {} + }, + "required": ["_tag", "defect"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Interrupt"] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "fiberId"], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "summary": "Subscribe to events" + } + }, + "/api/event/changes": { + "get": { + "tags": ["events"], + "operationId": "v2.event.changes", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/EventLog.ChangeStream" + } + }, + "required": ["id", "event", "data"], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Fail"] + }, + "error": { + "not": {} + } + }, + "required": ["_tag", "error"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Die"] + }, + "defect": {} + }, + "required": ["_tag", "defect"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Interrupt"] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "fiberId"], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", + "summary": "Subscribe to change hints" + } + }, + "/api/pty": { + "get": { + "tags": ["pty"], + "operationId": "v2.pty.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List PTY sessions for a location, including exited sessions retained until removal.", + "summary": "List PTY sessions" + }, + "post": { + "tags": ["pty"], + "operationId": "v2.pty.create", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Create a pseudo-terminal session for a location.", + "summary": "Create PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/pty/{ptyID}": { + "get": { + "tags": ["pty"], + "operationId": "v2.pty.get", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Get one PTY session, including its exit code once exited.", + "summary": "Get PTY session" + }, + "put": { + "tags": ["pty"], + "operationId": "v2.pty.update", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Update the title or viewport size of one PTY session.", + "summary": "Update PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "cols": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + } + }, + "required": ["rows", "cols"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": ["pty"], + "operationId": "v2.pty.remove", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Terminate and remove one PTY session.", + "summary": "Remove PTY session" + } + }, + "/api/pty/{ptyID}/connect-token": { + "post": { + "tags": ["pty"], + "operationId": "v2.pty.connectToken", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/PtyTicket.ConnectToken" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", + "summary": "Create PTY WebSocket token" + } + }, + "/api/pty/{ptyID}/connect": { + "get": { + "tags": ["pty"], + "operationId": "v2.pty.connect", + "x-websocket": true, + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "in": "query", + "name": "location[directory]", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "location[workspace]", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "ticket", + "schema": { + "type": "string" + } + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Establish a WebSocket connection streaming PTY output and accepting terminal input.", + "summary": "Connect to PTY session" + } + }, + "/api/shell": { + "get": { + "tags": ["shell"], + "operationId": "v2.shell.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Shell1" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List currently running shell commands for a location. Exited commands are not included.", + "summary": "List running shell commands" + }, + "post": { + "tags": ["shell"], + "operationId": "v2.shell.create", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "metadata": { + "type": "object" + } + }, + "required": ["command"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/shell/{id}": { + "get": { + "tags": ["shell"], + "operationId": "v2.shell.get", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Get one shell command, including its status and exit code once exited.", + "summary": "Get shell command" + }, + "delete": { + "tags": ["shell"], + "operationId": "v2.shell.remove", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Terminate and remove one shell command and its retained output.", + "summary": "Remove shell command" + } + }, + "/api/shell/{id}/output": { + "get": { + "tags": ["shell"], + "operationId": "v2.shell.output", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$" + } + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": ["output", "cursor", "size", "truncated"], + "additionalProperties": false + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Page through captured combined output by absolute byte cursor.", + "summary": "Read shell output" + } + }, + "/api/question/request": { + "get": { + "tags": ["session questions"], + "operationId": "v2.question.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Request" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending question requests for a location.", + "summary": "List pending question requests" + } + }, + "/api/session/{sessionID}/question": { + "get": { + "tags": ["session questions"], + "operationId": "v2.session.question.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Request" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending question requests owned by a session.", + "summary": "List session question requests" + } + }, + "/api/session/{sessionID}/question/{requestID}/reply": { + "post": { + "tags": ["session questions"], + "operationId": "v2.session.question.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | QuestionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Answer a pending question request owned by a session.", + "summary": "Reply to pending question request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuestionV2.Reply" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/question/{requestID}/reject": { + "post": { + "tags": ["session questions"], + "operationId": "v2.session.question.reject", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | QuestionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Reject a pending question request owned by a session.", + "summary": "Reject pending question request" + } + }, + "/api/reference": { + "get": { + "tags": ["reference"], + "operationId": "v2.reference.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Reference.Info" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List references available in the requested location.", + "summary": "List references" + } + }, + "/experimental/project/{projectID}/copy": { + "post": { + "tags": ["projectCopy"], + "operationId": "v2.projectCopy.create", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "ProjectCopy.Copy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCopy.Copy" + } + } + } + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "strategy": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["strategy", "directory"], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": ["projectCopy"], + "operationId": "v2.projectCopy.remove", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": ["directory", "force"], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/experimental/project/{projectID}/copy/refresh": { + "post": { + "tags": ["projectCopy"], + "operationId": "v2.projectCopy.refresh", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + } + } + }, + "/api/vcs/status": { + "get": { + "tags": ["vcs"], + "operationId": "v2.vcs.status", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Vcs.FileStatus" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List uncommitted working-copy changes relative to the requested location.", + "summary": "VCS status" + } + }, + "/api/vcs/diff": { + "get": { + "tags": ["vcs"], + "operationId": "v2.vcs.diff", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "mode", + "in": "query", + "schema": { + "$ref": "#/components/schemas/Vcs.Mode" + }, + "required": true + }, + { + "name": "context", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.", + "summary": "VCS diff" + } + } + }, + "components": { + "schemas": { + "UnauthorizedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["UnauthorizedError"] + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "InvalidRequestError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["InvalidRequestError"] + }, + "message": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "Location.Info": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": ["id", "directory"], + "additionalProperties": false + } + }, + "required": ["directory", "project"], + "additionalProperties": false + }, + "Model.Ref": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "Provider.Settings": { + "type": "object" + }, + "Provider.Request": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": ["settings", "headers", "body"], + "additionalProperties": false + }, + "Agent.Color": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^#[0-9a-fA-F]{6}$" + } + ] + }, + { + "type": "string", + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] + } + ] + }, + "PermissionV2.Effect": { + "type": "string", + "enum": ["allow", "deny", "ask"] + }, + "PermissionV2.Rule": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "effect": { + "$ref": "#/components/schemas/PermissionV2.Effect" + } + }, + "required": ["action", "resource", "effect"], + "additionalProperties": false + }, + "PermissionV2.Ruleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Rule" + } + }, + "AgentV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "request": { + "$ref": "#/components/schemas/Provider.Request" + }, + "system": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": ["subagent", "primary", "all"] + }, + "hidden": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Agent.Color" + }, + "steps": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "permissions": { + "$ref": "#/components/schemas/PermissionV2.Ruleset" + } + }, + "required": ["id", "request", "mode", "hidden", "permissions"], + "additionalProperties": false + }, + "Plugin.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "Location.Ref": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + } + }, + "required": ["directory"], + "additionalProperties": false + }, + "File.Diff": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["added", "modified", "deleted"] + }, + "additions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "deletions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "patch": { + "type": "string" + } + }, + "required": ["path", "status", "additions", "deletions", "patch"], + "additionalProperties": false + }, + "Revert.State": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File.Diff" + } + } + }, + "required": ["messageID"], + "additionalProperties": false + }, + "SessionV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "projectID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subpath": { + "type": "string" + }, + "revert": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": ["id", "projectID", "cost", "tokens", "time", "title", "location"], + "additionalProperties": false + }, + "SessionWatermarks": { + "type": "object", + "patternProperties": { + "^ses": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." + }, + "SessionsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "watermarks": { + "$ref": "#/components/schemas/SessionWatermarks" + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["data", "watermarks", "cursor"], + "additionalProperties": false + }, + "InvalidCursorError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["InvalidCursorError"] + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "InvalidRequestError1": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["InvalidRequestError"] + }, + "message": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "SessionActive": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["running"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + "SessionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["SessionNotFoundError"] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "sessionID", "message"], + "additionalProperties": false + }, + "MessageNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["MessageNotFoundError"] + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "sessionID", "messageID", "message"], + "additionalProperties": false + }, + "Prompt.Source": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": ["start", "end", "text"], + "additionalProperties": false + }, + "PromptInput.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["uri"], + "additionalProperties": false + }, + "Prompt.AgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["name"], + "additionalProperties": false + }, + "PromptInput": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptInput.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": ["text"], + "additionalProperties": false + }, + "Prompt.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["uri", "mime"], + "additionalProperties": false + }, + "Prompt": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": ["text"], + "additionalProperties": false + }, + "SessionInput.Admitted": { + "type": "object", + "properties": { + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + }, + "timeCreated": { + "type": "number" + }, + "promotedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["admittedSeq", "id", "sessionID", "prompt", "delivery", "timeCreated"], + "additionalProperties": false + }, + "ConflictError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ConflictError"] + }, + "message": { + "type": "string" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "CommandNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["CommandNotFoundError"] + }, + "command": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "command", "message"], + "additionalProperties": false + }, + "CommandEvaluationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["CommandEvaluationError"] + }, + "command": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "command", "message"], + "additionalProperties": false + }, + "SkillNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["SkillNotFoundError"] + }, + "skill": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "skill", "message"], + "additionalProperties": false + }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["SessionBusyError"] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "sessionID", "message"], + "additionalProperties": false + }, + "ServiceUnavailableError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ServiceUnavailableError"] + }, + "message": { + "type": "string" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "UnknownError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["UnknownError"] + }, + "message": { + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "Session.Message.AgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["agent-switched"] + }, + "agent": { + "type": "string" + } + }, + "required": ["id", "time", "type", "agent"], + "additionalProperties": false + }, + "Session.Message.ModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["model-switched"] + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": ["id", "time", "type", "model"], + "additionalProperties": false + }, + "Session.Message.User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + }, + "type": { + "type": "string", + "enum": ["user"] + } + }, + "required": ["id", "time", "text", "type"], + "additionalProperties": false + }, + "Session.Message.Synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["synthetic"] + } + }, + "required": ["id", "time", "sessionID", "text", "type"], + "additionalProperties": false + }, + "Session.Message.System": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["system"] + }, + "text": { + "type": "string" + } + }, + "required": ["id", "time", "type", "text"], + "additionalProperties": false + }, + "Session.Message.Skill": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["skill"] + }, + "name": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["id", "time", "type", "name", "text"], + "additionalProperties": false + }, + "Session.Message.Shell": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["shell"] + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["id", "time", "type", "callID", "command", "output"], + "additionalProperties": false + }, + "Session.Message.Assistant.Text": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["type", "id", "text"], + "additionalProperties": false + }, + "LLM.ProviderMetadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "Session.Message.Assistant.Reasoning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning"] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "id", "text"], + "additionalProperties": false + }, + "Session.Message.ToolState.Pending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "input": { + "type": "string" + } + }, + "required": ["status", "input"], + "additionalProperties": false + }, + "Tool.TextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "Tool.FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["file"] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"], + "additionalProperties": false + }, + "LLM.ToolContent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + }, + "Session.Message.ToolState.Running": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["running"] + }, + "input": { + "type": "object" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + } + }, + "required": ["status", "input", "structured", "content"], + "additionalProperties": false + }, + "Session.Message.ToolState.Completed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["completed"] + }, + "input": { + "type": "object" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "structured": { + "type": "object" + }, + "result": {} + }, + "required": ["status", "input", "content", "structured"], + "additionalProperties": false + }, + "Session.Error.Unknown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + "Session.Message.ToolState.Error": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["error"] + }, + "input": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "structured": { + "type": "object" + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + }, + "result": {} + }, + "required": ["status", "input", "content", "structured", "error"], + "additionalProperties": false + }, + "Session.Message.Assistant.Tool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tool"] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + }, + "resultMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Error" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "pruned": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "id", "name", "state", "time"], + "additionalProperties": false + }, + "Session.Message.Assistant": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["assistant"] + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Assistant.Text" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant.Reasoning" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant.Tool" + } + ] + } + }, + "snapshot": { + "type": "object", + "properties": { + "start": { + "type": "string" + }, + "end": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": ["id", "time", "type", "agent", "model", "content"], + "additionalProperties": false + }, + "Session.Message.Compaction": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["compaction"] + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "reason", "summary", "recent", "id", "time"], + "additionalProperties": false + }, + "Session.Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.AgentSwitched" + }, + { + "$ref": "#/components/schemas/Session.Message.ModelSwitched" + }, + { + "$ref": "#/components/schemas/Session.Message.User" + }, + { + "$ref": "#/components/schemas/Session.Message.Synthetic" + }, + { + "$ref": "#/components/schemas/Session.Message.System" + }, + { + "$ref": "#/components/schemas/Session.Message.Skill" + }, + { + "$ref": "#/components/schemas/Session.Message.Shell" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction" + } + ] + }, + "SessionContextEntry.Key": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Context entry key (lowercase alphanumerics plus . _ -)" + } + ] + }, + "SessionContextEntry.Info": { + "type": "object", + "properties": { + "key": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "value": {} + }, + "required": ["key", "value"], + "additionalProperties": false + }, + "session.next.agent.switched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.model.switched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": ["timestamp", "sessionID", "messageID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.moved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.moved"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subdirectory": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "location"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.renamed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.renamed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "title": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "title"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.forked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.forked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": ["timestamp", "sessionID", "parentID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.prompted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.prompt.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.context.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.skill.activated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.skill.activated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "name": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "name", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.shell.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.shell.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.step.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.step.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.step.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.text.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.text.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.tool.input.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "LLM.ProviderMetadata3": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.tool.called": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata3" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.tool.progress": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "LLM.ProviderMetadata4": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.tool.success": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata4" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "LLM.ProviderMetadata5": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.tool.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata5" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "LLM.ProviderMetadata6": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.reasoning.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata6" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "LLM.ProviderMetadata7": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata7" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.retry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "number" + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"], + "additionalProperties": false + }, + "session.next.retried": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/session.next.retry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.compaction.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.revert.staged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.staged"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "revert": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.revert.cleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.cleared"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.revert.committed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionDurableEvent": { + "oneOf": [ + { + "$ref": "#/components/schemas/session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/session.next.model.switched" + }, + { + "$ref": "#/components/schemas/session.next.moved" + }, + { + "$ref": "#/components/schemas/session.next.renamed" + }, + { + "$ref": "#/components/schemas/session.next.forked" + }, + { + "$ref": "#/components/schemas/session.next.prompted" + }, + { + "$ref": "#/components/schemas/session.next.prompt.admitted" + }, + { + "$ref": "#/components/schemas/session.next.context.updated" + }, + { + "$ref": "#/components/schemas/session.next.synthetic" + }, + { + "$ref": "#/components/schemas/session.next.skill.activated" + }, + { + "$ref": "#/components/schemas/session.next.shell.started" + }, + { + "$ref": "#/components/schemas/session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/session.next.step.started" + }, + { + "$ref": "#/components/schemas/session.next.step.ended" + }, + { + "$ref": "#/components/schemas/session.next.step.failed" + }, + { + "$ref": "#/components/schemas/session.next.text.started" + }, + { + "$ref": "#/components/schemas/session.next.text.ended" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/session.next.tool.called" + }, + { + "$ref": "#/components/schemas/session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/session.next.tool.success" + }, + { + "$ref": "#/components/schemas/session.next.tool.failed" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/session.next.retried" + }, + { + "$ref": "#/components/schemas/session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/session.next.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.next.revert.staged" + }, + { + "$ref": "#/components/schemas/session.next.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.next.revert.committed" + } + ] + }, + "EventLog.Synced": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log.synced"] + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["type", "aggregateID"], + "additionalProperties": false, + "description": "Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq." + }, + "SessionLogItem": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionDurableEvent" + }, + { + "$ref": "#/components/schemas/EventLog.Synced" + } + ] + }, + "SessionLogItemStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/SessionLogItem" + }, + "contentMediaType": "application/json" + }, + "SessionMessagesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Message" + } + }, + "watermark": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["data", "cursor"], + "additionalProperties": false + }, + "Model.Api": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "package"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "settings"], + "additionalProperties": false + } + ] + }, + "Model.Capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "Model.Cost": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + }, + "ModelV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "api": { + "$ref": "#/components/schemas/Model.Api" + }, + "capabilities": { + "$ref": "#/components/schemas/Model.Capabilities" + }, + "request": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "variant": { + "type": "string" + } + }, + "required": ["settings", "headers", "body"], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": ["id", "settings", "headers", "body"], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "type": "number" + } + }, + "required": ["released"], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Model.Cost" + } + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated", "active"] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": ["context", "output"], + "additionalProperties": false + } + }, + "required": [ + "id", + "providerID", + "name", + "api", + "capabilities", + "request", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, + "GenerateTextResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false + }, + "Provider.AISDK": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["type", "package"], + "additionalProperties": false + }, + "Provider.Native": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["type", "settings"], + "additionalProperties": false + }, + "Provider.Api": { + "anyOf": [ + { + "$ref": "#/components/schemas/Provider.AISDK" + }, + { + "$ref": "#/components/schemas/Provider.Native" + } + ] + }, + "ProviderV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "name": { + "type": "string" + }, + "disabled": { + "type": "boolean" + }, + "api": { + "$ref": "#/components/schemas/Provider.Api" + }, + "request": { + "$ref": "#/components/schemas/Provider.Request" + } + }, + "required": ["id", "name", "api", "request"], + "additionalProperties": false + }, + "ProviderNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ProviderNotFoundError"] + }, + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "providerID", "message"], + "additionalProperties": false + }, + "Integration.When": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + }, + "Integration.TextPrompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "when": { + "$ref": "#/components/schemas/Integration.When" + } + }, + "required": ["type", "key", "message"], + "additionalProperties": false + }, + "Integration.SelectPrompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["select"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "when": { + "$ref": "#/components/schemas/Integration.When" + } + }, + "required": ["type", "key", "message", "options"], + "additionalProperties": false + }, + "Integration.OAuthMethod": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["oauth"] + }, + "label": { + "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.TextPrompt" + }, + { + "$ref": "#/components/schemas/Integration.SelectPrompt" + } + ] + } + } + }, + "required": ["id", "type", "label"], + "additionalProperties": false + }, + "Integration.KeyMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["key"] + }, + "label": { + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "Integration.EnvMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["env"] + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "names"], + "additionalProperties": false + }, + "Integration.Method": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.OAuthMethod" + }, + { + "$ref": "#/components/schemas/Integration.KeyMethod" + }, + { + "$ref": "#/components/schemas/Integration.EnvMethod" + } + ] + }, + "Connection.CredentialInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["credential"] + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": ["type", "id", "label"], + "additionalProperties": false + }, + "Connection.EnvInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["env"] + }, + "name": { + "type": "string" + } + }, + "required": ["type", "name"], + "additionalProperties": false + }, + "Connection.Info": { + "anyOf": [ + { + "$ref": "#/components/schemas/Connection.CredentialInfo" + }, + { + "$ref": "#/components/schemas/Connection.EnvInfo" + } + ] + }, + "Integration.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Integration.Method" + } + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Connection.Info" + } + } + }, + "required": ["id", "name", "methods", "connections"], + "additionalProperties": false + }, + "Integration.Attempt": { + "type": "object", + "properties": { + "attemptID": { + "type": "string" + }, + "url": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": ["auto", "code"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["attemptID", "url", "instructions", "mode", "time"], + "additionalProperties": false + }, + "Integration.AttemptStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["complete"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "message": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "message", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["expired"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + } + ] + }, + "Mcp.Status.Connected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["connected"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "Mcp.Status.Disconnected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["disconnected"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "Mcp.Status.Disabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["disabled"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "Mcp.Status.Failed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "Mcp.Status.NeedsAuth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_auth"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "Mcp.Status.NeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_client_registration"] + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "Mcp.Server": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/Mcp.Status.Connected" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Disconnected" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Disabled" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Failed" + }, + { + "$ref": "#/components/schemas/Mcp.Status.NeedsAuth" + }, + { + "$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration" + } + ] + }, + "integrationID": { + "type": "string" + } + }, + "required": ["name", "status"], + "additionalProperties": false + }, + "Project.Current": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": ["id", "directory"], + "additionalProperties": false + }, + "Project.Directory": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "strategy": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + }, + "Project.Directories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project.Directory" + } + }, + "Form.Metadata": { + "type": "object" + }, + "Form.When": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + { + "type": "boolean" + } + ] + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + }, + "Form.Option": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["value", "label"], + "additionalProperties": false + }, + "Form.StringField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": ["string"] + }, + "format": { + "type": "string", + "enum": ["email", "uri", "date", "date-time"] + }, + "minLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "pattern": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "default": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "custom": { + "type": "boolean" + } + }, + "required": ["key", "type"], + "additionalProperties": false + }, + "Form.NumberField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": ["number"] + }, + "minimum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "maximum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "default": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["key", "type"], + "additionalProperties": false + }, + "Form.IntegerField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": ["integer"] + }, + "minimum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "maximum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "default": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["key", "type"], + "additionalProperties": false + }, + "Form.BooleanField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": ["boolean"] + }, + "default": { + "type": "boolean" + } + }, + "required": ["key", "type"], + "additionalProperties": false + }, + "Form.MultiselectField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": ["multiselect"] + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "minItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "custom": { + "type": "boolean" + }, + "default": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["key", "type", "options"], + "additionalProperties": false + }, + "Form.FormInfo": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": ["form"] + }, + "fields": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField" + }, + { + "$ref": "#/components/schemas/Form.NumberField" + }, + { + "$ref": "#/components/schemas/Form.IntegerField" + }, + { + "$ref": "#/components/schemas/Form.BooleanField" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField" + } + ] + } + } + }, + "required": ["id", "sessionID", "mode", "fields"], + "additionalProperties": false + }, + "Form.UrlInfo": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": ["url"] + }, + "url": { + "type": "string" + } + }, + "required": ["id", "sessionID", "mode", "url"], + "additionalProperties": false + }, + "Form.CreatePayload": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": ["form", "url"] + }, + "fields": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField" + }, + { + "$ref": "#/components/schemas/Form.NumberField" + }, + { + "$ref": "#/components/schemas/Form.IntegerField" + }, + { + "$ref": "#/components/schemas/Form.BooleanField" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField" + } + ] + } + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["mode"], + "additionalProperties": false + }, + "FormNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["FormNotFoundError"] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "id", "message"], + "additionalProperties": false + }, + "Form.Value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "Form.Answer": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value" + } + }, + "Form.State": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["answered"] + }, + "answer": { + "$ref": "#/components/schemas/Form.Answer" + } + }, + "required": ["status", "answer"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["cancelled"] + } + }, + "required": ["status"], + "additionalProperties": false + } + ] + }, + "Form.Reply": { + "type": "object", + "properties": { + "answer": { + "$ref": "#/components/schemas/Form.Answer" + } + }, + "required": ["answer"], + "additionalProperties": false + }, + "FormAlreadySettledError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["FormAlreadySettledError"] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "id", "message"], + "additionalProperties": false + }, + "FormInvalidAnswerError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["FormInvalidAnswerError"] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "id", "message"], + "additionalProperties": false + }, + "PermissionV2.Source": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tool"] + }, + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["type", "messageID", "callID"], + "additionalProperties": false + } + ] + }, + "PermissionV2.Request": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + } + }, + "required": ["id", "sessionID", "action", "resources"], + "additionalProperties": false + }, + "PermissionSaved.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "action": { + "type": "string" + }, + "resource": { + "type": "string" + } + }, + "required": ["id", "projectID", "action", "resource"], + "additionalProperties": false + }, + "PermissionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["PermissionNotFoundError"] + }, + "requestID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "requestID", "message"], + "additionalProperties": false + }, + "PermissionV2.Reply": { + "type": "string", + "enum": ["once", "always", "reject"] + }, + "FileSystem.Entry": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file", "directory"] + } + }, + "required": ["path", "type"], + "additionalProperties": false + }, + "CommandV2.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "template": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "subtask": { + "type": "boolean" + } + }, + "required": ["name", "template"], + "additionalProperties": false + }, + "SkillV2.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "slash": { + "type": "boolean" + }, + "autoinvoke": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": ["name", "location", "content"], + "additionalProperties": false + }, + "models-dev.refreshed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "integration.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["integration.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "integration.connection.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["integration.connection.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": ["integrationID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "catalog.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["catalog.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "agent.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["agent.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SnapshotFileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] + } + }, + "required": ["additions", "deletions"], + "additionalProperties": false + }, + "PermissionAction": { + "type": "string", + "enum": ["allow", "deny", "ask"] + }, + "PermissionRule": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/PermissionAction" + } + }, + "required": ["permission", "pattern", "action"], + "additionalProperties": false + }, + "PermissionRuleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRule" + } + }, + "Session": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updated": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "compacting": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "partID": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, + "session.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "JSONSchema": { + "type": "object" + }, + "OutputFormat": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["json_schema"] + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "anyOf": [ + { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["type", "schema"], + "additionalProperties": false + } + ] + }, + "UserMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "role": { + "type": "string", + "enum": ["user"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["created"], + "additionalProperties": false + }, + "format": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputFormat" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "object", + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["diffs"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "variant": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "system": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "role", "time", "agent", "model"], + "additionalProperties": false + }, + "ProviderAuthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["ProviderAuthError"] + }, + "data": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["providerID", "message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "UnknownError1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["UnknownError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "MessageOutputLengthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["MessageOutputLengthError"] + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "MessageAbortedError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["MessageAbortedError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "StructuredOutputError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["StructuredOutputError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "retries": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["message", "retries"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "ContextOverflowError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["ContextOverflowError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "responseBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "ContentFilterError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["ContentFilterError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "APIError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["APIError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "responseBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "required": ["message", "isRetryable"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "AssistantMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "role": { + "type": "string", + "enum": ["assistant"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "completed": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["created"], + "additionalProperties": false + }, + "error": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError1" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "path": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": ["cwd", "root"], + "additionalProperties": false + }, + "summary": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "structured": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "variant": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "finish": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ], + "additionalProperties": false + }, + "Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, + { + "$ref": "#/components/schemas/AssistantMessage" + } + ] + }, + "message.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "message.removed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TextPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + }, + "synthetic": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ignored": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "time": { + "anyOf": [ + { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["start"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "messageID", "type", "text"], + "additionalProperties": false + }, + "SubtaskPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["subtask"] + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"], + "additionalProperties": false + }, + "ReasoningPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["reasoning"] + }, + "text": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["start"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "messageID", "type", "text", "time"], + "additionalProperties": false + }, + "FilePartSourceText": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": ["value", "start", "end"], + "additionalProperties": false + }, + "FileSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "path": { + "type": "string" + } + }, + "required": ["text", "type", "path"], + "additionalProperties": false + }, + "Range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "character": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["line", "character"], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "character": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["line", "character"], + "additionalProperties": false + } + }, + "required": ["start", "end"], + "additionalProperties": false + }, + "SymbolSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": ["symbol"] + }, + "path": { + "type": "string" + }, + "range": { + "$ref": "#/components/schemas/Range" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["text", "type", "path", "range", "name", "kind"], + "additionalProperties": false + }, + "ResourceSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": ["resource"] + }, + "clientName": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": ["text", "type", "clientName", "uri"], + "additionalProperties": false + }, + "FilePartSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileSource" + }, + { + "$ref": "#/components/schemas/SymbolSource" + }, + { + "$ref": "#/components/schemas/ResourceSource" + } + ] + }, + "FilePart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/FilePartSource" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "messageID", "type", "mime", "url"], + "additionalProperties": false + }, + "ToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "input": { + "type": "object" + }, + "raw": { + "type": "string" + } + }, + "required": ["status", "input", "raw"], + "additionalProperties": false + }, + "ToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["running"] + }, + "input": { + "type": "object" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["start"], + "additionalProperties": false + } + }, + "required": ["status", "input", "time"], + "additionalProperties": false + }, + "ToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["completed"] + }, + "input": { + "type": "object" + }, + "output": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "compacted": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["start", "end"], + "additionalProperties": false + }, + "attachments": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePart" + } + }, + { + "type": "null" + } + ] + } + }, + "required": ["status", "input", "output", "title", "metadata", "time"], + "additionalProperties": false + }, + "ToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["error"] + }, + "input": { + "type": "object" + }, + "error": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["start", "end"], + "additionalProperties": false + } + }, + "required": ["status", "input", "error", "time"], + "additionalProperties": false + }, + "ToolState": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolStatePending" + }, + { + "$ref": "#/components/schemas/ToolStateRunning" + }, + { + "$ref": "#/components/schemas/ToolStateCompleted" + }, + { + "$ref": "#/components/schemas/ToolStateError" + } + ] + }, + "ToolPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["tool"] + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ToolState" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"], + "additionalProperties": false + }, + "StepStartPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["step-start"] + }, + "snapshot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "messageID", "type"], + "additionalProperties": false + }, + "StepFinishPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["step-finish"] + }, + "reason": { + "type": "string" + }, + "snapshot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"], + "additionalProperties": false + }, + "SnapshotPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["snapshot"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["id", "sessionID", "messageID", "type", "snapshot"], + "additionalProperties": false + }, + "PatchPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["patch"] + }, + "hash": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "sessionID", "messageID", "type", "hash", "files"], + "additionalProperties": false + }, + "AgentPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["agent"] + }, + "name": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["value", "start", "end"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "messageID", "type", "name"], + "additionalProperties": false + }, + "RetryPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["retry"] + }, + "attempt": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "error": { + "$ref": "#/components/schemas/APIError" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"], + "additionalProperties": false + }, + "CompactionPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": ["compaction"] + }, + "auto": { + "type": "boolean" + }, + "overflow": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "tail_start_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "messageID", "type", "auto"], + "additionalProperties": false + }, + "Part": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPart" + }, + { + "$ref": "#/components/schemas/SubtaskPart" + }, + { + "$ref": "#/components/schemas/ReasoningPart" + }, + { + "$ref": "#/components/schemas/FilePart" + }, + { + "$ref": "#/components/schemas/ToolPart" + }, + { + "$ref": "#/components/schemas/StepStartPart" + }, + { + "$ref": "#/components/schemas/StepFinishPart" + }, + { + "$ref": "#/components/schemas/SnapshotPart" + }, + { + "$ref": "#/components/schemas/PatchPart" + }, + { + "$ref": "#/components/schemas/AgentPart" + }, + { + "$ref": "#/components/schemas/RetryPart" + }, + { + "$ref": "#/components/schemas/CompactionPart" + } + ] + }, + "message.part.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "message.part.removed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "partID": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.execution.settled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.execution.settled"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "outcome": { + "type": "string", + "enum": ["success", "failure", "interrupted"] + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": ["timestamp", "sessionID", "outcome"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.text.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.reasoning.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.tool.input.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.next.compaction.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "file.edited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "reference.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "permission.v2.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.v2.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + } + }, + "required": ["id", "sessionID", "action", "resources"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "permission.v2.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.v2.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "reply": { + "$ref": "#/components/schemas/PermissionV2.Reply" + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "plugin.added": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "project.directories.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["project.directories.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": ["projectID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "command.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["command.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "skill.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["skill.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "file.watcher.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "Pty": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "title": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["running", "exited"] + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exitCode": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], + "additionalProperties": false + }, + "pty.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "pty.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "pty.exited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "exitCode": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "pty.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "Shell": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": ["running", "exited", "timeout", "killed"] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "completed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["started"], + "additionalProperties": false + } + }, + "required": ["id", "status", "command", "cwd", "shell", "file", "metadata", "time"], + "additionalProperties": false + }, + "shell.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["shell.created"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Shell" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "shell.exited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["shell.exited"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "status": { + "type": "string", + "enum": ["running", "exited", "timeout", "killed"] + } + }, + "required": ["id", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "shell.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["shell.deleted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionV2.Option": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display text (1-5 words, concise)" + }, + "description": { + "type": "string", + "description": "Explanation of choice" + } + }, + "required": ["label", "description"], + "additionalProperties": false + }, + "QuestionV2.Info": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Complete question" + }, + "header": { + "type": "string", + "description": "Very short label (max 30 chars)" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Option" + }, + "description": "Available choices" + }, + "multiple": { + "type": "boolean" + }, + "custom": { + "type": "boolean" + } + }, + "required": ["question", "header", "options"], + "additionalProperties": false + }, + "QuestionV2.Tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + }, + "question.v2.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2.Tool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionV2.Answer": { + "type": "array", + "items": { + "type": "string" + } + }, + "question.v2.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Answer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "question.v2.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.rejected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "Form.Metadata1": { + "type": "object" + }, + "Form.When1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "boolean" + } + ] + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + }, + "Form.StringField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": ["string"] + }, + "format": { + "type": "string", + "enum": ["email", "uri", "date", "date-time"] + }, + "minLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "pattern": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "default": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "custom": { + "type": "boolean" + } + }, + "required": ["key", "type"], + "additionalProperties": false + }, + "Form.NumberField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": ["number"] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "default": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["key", "type"], + "additionalProperties": false + }, + "Form.IntegerField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": ["integer"] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + "default": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["key", "type"], + "additionalProperties": false + }, + "Form.BooleanField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": ["boolean"] + }, + "default": { + "type": "boolean" + } + }, + "required": ["key", "type"], + "additionalProperties": false + }, + "Form.MultiselectField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": ["multiselect"] + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "minItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "custom": { + "type": "boolean" + }, + "default": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["key", "type", "options"], + "additionalProperties": false + }, + "Form.FormInfo1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata1" + }, + "mode": { + "type": "string", + "enum": ["form"] + }, + "fields": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField1" + }, + { + "$ref": "#/components/schemas/Form.NumberField1" + }, + { + "$ref": "#/components/schemas/Form.IntegerField1" + }, + { + "$ref": "#/components/schemas/Form.BooleanField1" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField1" + } + ] + } + } + }, + "required": ["id", "sessionID", "mode", "fields"], + "additionalProperties": false + }, + "Form.UrlInfo1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata1" + }, + "mode": { + "type": "string", + "enum": ["url"] + }, + "url": { + "type": "string" + } + }, + "required": ["id", "sessionID", "mode", "url"], + "additionalProperties": false + }, + "form.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["form.created"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "form": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo1" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo1" + } + ] + } + }, + "required": ["form"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "Form.Value1": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "Form.Answer1": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value1" + } + }, + "form.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["form.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "answer": { + "$ref": "#/components/schemas/Form.Answer1" + } + }, + "required": ["id", "sessionID", "answer"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "form.cancelled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["form.cancelled"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + } + }, + "required": ["id", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Brief description of the task" + }, + "status": { + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" + }, + "priority": { + "type": "string", + "description": "Priority level of the task: high, medium, low" + } + }, + "required": ["content", "status", "priority"], + "additionalProperties": false + }, + "todo.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["idle"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["retry"] + }, + "attempt": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "message": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + } + }, + "required": ["reason", "provider", "title", "message", "label"], + "additionalProperties": false + }, + "next": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["type", "attempt", "message", "next"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["busy"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + }, + "session.status": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.idle": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "tui.prompt.append": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "tui.command.execute": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.background", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "tui.session.select": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses", + "description": "Session ID to navigate to" + } + ] + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "installation.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "installation.update-available": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "vcs.branch.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "mcp.status.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["mcp.status.changed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "permission.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "permission.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display text (1-5 words, concise)" + }, + "description": { + "type": "string", + "description": "Explanation of choice" + } + }, + "required": ["label", "description"], + "additionalProperties": false + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Complete question" + }, + "header": { + "type": "string", + "description": "Very short label (max 30 chars)" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + }, + "description": "Available choices" + }, + "multiple": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Allow selecting multiple choices" + }, + "custom": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Allow typing a custom answer (default: true)" + } + }, + "required": ["question", "header", "options"], + "additionalProperties": false + }, + "QuestionTool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + }, + "question.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionTool" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "question.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "question.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "session.error": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError1" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2Event.server.connected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "durable": { + "anyOf": [ + { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/Location.Ref" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/models-dev.refreshed" + }, + { + "$ref": "#/components/schemas/integration.updated" + }, + { + "$ref": "#/components/schemas/integration.connection.updated" + }, + { + "$ref": "#/components/schemas/catalog.updated" + }, + { + "$ref": "#/components/schemas/agent.updated" + }, + { + "$ref": "#/components/schemas/session.created" + }, + { + "$ref": "#/components/schemas/session.updated" + }, + { + "$ref": "#/components/schemas/session.deleted" + }, + { + "$ref": "#/components/schemas/message.updated" + }, + { + "$ref": "#/components/schemas/message.removed" + }, + { + "$ref": "#/components/schemas/message.part.updated" + }, + { + "$ref": "#/components/schemas/message.part.removed" + }, + { + "$ref": "#/components/schemas/session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/session.next.model.switched" + }, + { + "$ref": "#/components/schemas/session.next.moved" + }, + { + "$ref": "#/components/schemas/session.next.renamed" + }, + { + "$ref": "#/components/schemas/session.next.forked" + }, + { + "$ref": "#/components/schemas/session.next.prompted" + }, + { + "$ref": "#/components/schemas/session.next.prompt.admitted" + }, + { + "$ref": "#/components/schemas/session.next.execution.settled" + }, + { + "$ref": "#/components/schemas/session.next.context.updated" + }, + { + "$ref": "#/components/schemas/session.next.synthetic" + }, + { + "$ref": "#/components/schemas/session.next.skill.activated" + }, + { + "$ref": "#/components/schemas/session.next.shell.started" + }, + { + "$ref": "#/components/schemas/session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/session.next.step.started" + }, + { + "$ref": "#/components/schemas/session.next.step.ended" + }, + { + "$ref": "#/components/schemas/session.next.step.failed" + }, + { + "$ref": "#/components/schemas/session.next.text.started" + }, + { + "$ref": "#/components/schemas/session.next.text.delta" + }, + { + "$ref": "#/components/schemas/session.next.text.ended" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/session.next.tool.called" + }, + { + "$ref": "#/components/schemas/session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/session.next.tool.success" + }, + { + "$ref": "#/components/schemas/session.next.tool.failed" + }, + { + "$ref": "#/components/schemas/session.next.retried" + }, + { + "$ref": "#/components/schemas/session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/session.next.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.next.revert.staged" + }, + { + "$ref": "#/components/schemas/session.next.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.next.revert.committed" + }, + { + "$ref": "#/components/schemas/file.edited" + }, + { + "$ref": "#/components/schemas/reference.updated" + }, + { + "$ref": "#/components/schemas/permission.v2.asked" + }, + { + "$ref": "#/components/schemas/permission.v2.replied" + }, + { + "$ref": "#/components/schemas/plugin.added" + }, + { + "$ref": "#/components/schemas/project.directories.updated" + }, + { + "$ref": "#/components/schemas/command.updated" + }, + { + "$ref": "#/components/schemas/skill.updated" + }, + { + "$ref": "#/components/schemas/file.watcher.updated" + }, + { + "$ref": "#/components/schemas/pty.created" + }, + { + "$ref": "#/components/schemas/pty.updated" + }, + { + "$ref": "#/components/schemas/pty.exited" + }, + { + "$ref": "#/components/schemas/pty.deleted" + }, + { + "$ref": "#/components/schemas/shell.created" + }, + { + "$ref": "#/components/schemas/shell.exited" + }, + { + "$ref": "#/components/schemas/shell.deleted" + }, + { + "$ref": "#/components/schemas/question.v2.asked" + }, + { + "$ref": "#/components/schemas/question.v2.replied" + }, + { + "$ref": "#/components/schemas/question.v2.rejected" + }, + { + "$ref": "#/components/schemas/form.created" + }, + { + "$ref": "#/components/schemas/form.replied" + }, + { + "$ref": "#/components/schemas/form.cancelled" + }, + { + "$ref": "#/components/schemas/todo.updated" + }, + { + "$ref": "#/components/schemas/session.status" + }, + { + "$ref": "#/components/schemas/session.idle" + }, + { + "$ref": "#/components/schemas/tui.prompt.append" + }, + { + "$ref": "#/components/schemas/tui.command.execute" + }, + { + "$ref": "#/components/schemas/tui.toast.show" + }, + { + "$ref": "#/components/schemas/tui.session.select" + }, + { + "$ref": "#/components/schemas/installation.updated" + }, + { + "$ref": "#/components/schemas/installation.update-available" + }, + { + "$ref": "#/components/schemas/vcs.branch.updated" + }, + { + "$ref": "#/components/schemas/mcp.status.changed" + }, + { + "$ref": "#/components/schemas/permission.asked" + }, + { + "$ref": "#/components/schemas/permission.replied" + }, + { + "$ref": "#/components/schemas/question.asked" + }, + { + "$ref": "#/components/schemas/question.replied" + }, + { + "$ref": "#/components/schemas/question.rejected" + }, + { + "$ref": "#/components/schemas/session.error" + }, + { + "$ref": "#/components/schemas/V2Event.server.connected" + } + ] + }, + "V2EventStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/V2Event" + }, + "contentMediaType": "application/json" + }, + "EventLog.Hint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log.hint"] + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["type", "aggregateID", "seq"], + "additionalProperties": false, + "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." + }, + "EventLog.SweepRequired": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["log.sweep_required"] + } + }, + "required": ["type"], + "additionalProperties": false, + "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." + }, + "EventLog.Change": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventLog.Hint" + }, + { + "$ref": "#/components/schemas/EventLog.SweepRequired" + } + ] + }, + "EventLog.ChangeStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/EventLog.Change" + }, + "contentMediaType": "application/json" + }, + "PtyNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["PtyNotFoundError"] + }, + "ptyID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "ptyID", "message"], + "additionalProperties": false + }, + "PtyTicket.ConnectToken": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false + }, + "ForbiddenError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ForbiddenError"] + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "Shell1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": ["running", "exited", "timeout", "killed"] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "completed": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["started"], + "additionalProperties": false + } + }, + "required": ["id", "status", "command", "cwd", "shell", "file", "metadata", "time"], + "additionalProperties": false + }, + "ShellNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ShellNotFoundError"] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "id", "message"], + "additionalProperties": false + }, + "QuestionV2.Request": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2.Tool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + }, + "QuestionV2.Reply": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Answer" + }, + "description": "User answers in order of questions (each answer is an array of selected labels)" + } + }, + "required": ["answers"], + "additionalProperties": false + }, + "QuestionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["QuestionNotFoundError"] + }, + "requestID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "requestID", "message"], + "additionalProperties": false + }, + "Reference.LocalSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["local"] + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": ["type", "path"], + "additionalProperties": false + }, + "Reference.GitSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["git"] + }, + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": ["type", "repository"], + "additionalProperties": false + }, + "Reference.Source": { + "anyOf": [ + { + "$ref": "#/components/schemas/Reference.LocalSource" + }, + { + "$ref": "#/components/schemas/Reference.GitSource" + } + ] + }, + "Reference.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "source": { + "$ref": "#/components/schemas/Reference.Source" + } + }, + "required": ["name", "path", "source"], + "additionalProperties": false + }, + "ProjectCopy.Copy": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + }, + "ProjectCopyError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["ProjectCopyError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "forceRequired": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "Vcs.FileStatus": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "additions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "deletions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] + } + }, + "required": ["file", "additions", "deletions", "status"], + "additionalProperties": false + }, + "Vcs.Mode": { + "type": "string", + "enum": ["working", "branch"] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [ + { + "name": "server.health" + }, + { + "name": "server.location" + }, + { + "name": "server.agent" + }, + { + "name": "plugins", + "description": "Experimental plugin routes." + }, + { + "name": "sessions", + "description": "Experimental session routes." + }, + { + "name": "messages", + "description": "Experimental message routes." + }, + { + "name": "models", + "description": "Experimental model routes." + }, + { + "name": "generate", + "description": "Experimental one-shot generation routes." + }, + { + "name": "providers", + "description": "Experimental provider routes." + }, + { + "name": "integrations", + "description": "Integration discovery and authentication routes." + }, + { + "name": "mcp", + "description": "MCP server status routes." + }, + { + "name": "server.credential" + }, + { + "name": "projects", + "description": "Location-scoped project routes." + }, + { + "name": "forms", + "description": "Session form routes." + }, + { + "name": "permissions", + "description": "Experimental permission routes." + }, + { + "name": "filesystem", + "description": "Experimental location-scoped filesystem routes." + }, + { + "name": "commands", + "description": "Experimental command routes." + }, + { + "name": "skills", + "description": "Experimental skill routes." + }, + { + "name": "events", + "description": "Experimental event stream routes." + }, + { + "name": "pty", + "description": "Experimental location-scoped PTY routes." + }, + { + "name": "shell", + "description": "Experimental location-scoped shell command routes." + }, + { + "name": "session questions", + "description": "Experimental session question routes." + }, + { + "name": "reference", + "description": "Location-scoped project references." + }, + { + "name": "projectCopy", + "description": "Project copy management routes." + }, + { + "name": "vcs", + "description": "Location-scoped version control routes." + } + ] +} diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..12a094e033b6de366467086d178f08788e9dd03b --- /dev/null +++ b/packages/codemode/test/openapi.test.ts @@ -0,0 +1,964 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, Option } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { CodeMode, OpenAPI, Tool } from "../src/index.js" +import { inputTypeScript, outputTypeScript } from "../src/tool-schema.js" + +const baseUrl = "http://localhost:4096" +type Document = OpenAPI.Document + +type Recorded = { + readonly method: string + readonly url: string + readonly headers: Record + readonly body: unknown +} + +const opencodeSpec = async (): Promise => { + return Bun.file(new URL("./fixtures/opencode-v2-openapi.json", import.meta.url)).json() as Promise +} + +const happyPathSpec = async (): Promise => { + return Bun.file(new URL("./fixtures/openapi-happy-path.json", import.meta.url)).json() as Promise +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const toolAt = (tools: unknown, name: string) => + name.split(".").reduce((current, segment) => (isRecord(current) ? current[segment] : undefined), tools) + +const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => { + const requests: Array = [] + const layer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request) => + Effect.gen(function* () { + const body = + request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined + const url = Option.map(HttpClientRequest.toUrl(request), (resolved) => resolved.toString()) + requests.push({ + method: request.method, + url: Option.getOrElse(url, () => request.url), + headers: { ...request.headers }, + body, + }) + return HttpClientResponse.fromWeb(request, respond(request)) + }), + ), + ) + return { requests, layer } +} + +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) + +const singleOperation = (operation: Record, method = "get"): Document => ({ + openapi: "3.1.0", + paths: { + "/test": { [method]: { operationId: "test", responses: { 200: { description: "Success" } }, ...operation } }, + }, +}) + +describe("OpenAPI.fromSpec", () => { + test("covers a representative API from generation through execution", async () => { + const resolutions: Array = [] + const client = recordingClient((request) => { + const url = Option.getOrElse(HttpClientRequest.toUrl(request), () => new URL(request.url)) + if (request.method === "POST") { + return new Response( + JSON.stringify({ id: "user-2", name: "Grace", email: "grace@example.test", role: "admin" }), + { status: 201, headers: { "content-type": "application/vnd.example+json" } }, + ) + } + if (request.method === "DELETE") return new Response(null, { status: 204 }) + if (url.pathname === "/search") { + return new Response("2 matches", { headers: { "content-type": "text/plain" } }) + } + return json({ id: "user-1", name: "Ada", email: "ada@example.test", role: "member" }) + }) + const api = OpenAPI.fromSpec({ + spec: await happyPathSpec(), + baseUrl, + auth: { + resolve: ({ name }) => { + resolutions.push(name) + return Effect.succeed( + name === "BearerAuth" + ? { type: "bearer", token: "bearer-secret" } + : { type: "apiKey", value: "api-secret" }, + ) + }, + }, + }) + const get = toolAt(api.tools, "users.get") + const create = toolAt(api.tools, "users.create") + const search = toolAt(api.tools, "search.run") + const remove = toolAt(api.tools, "users.remove") + + expect(api.skipped).toEqual([]) + if ( + !Tool.isDefinition(get) || + !Tool.isDefinition(create) || + !Tool.isDefinition(search) || + !Tool.isDefinition(remove) + ) { + throw new Error("happy-path fixture did not generate every operation") + } + expect(inputTypeScript(get)).toBe( + '{ userId: string; include?: Array; verbose?: boolean; "X-Trace-ID"?: string }', + ) + expect(inputTypeScript(create)).toBe('{ name: string; email: string; role?: "admin" | "member" }') + expect(inputTypeScript(search)).toBe("{ filter?: { query: string; page?: number }; tags?: Array }") + expect(inputTypeScript(remove)).toBe("{ userId: string }") + expect(outputTypeScript(get)).toContain("id: string") + expect(outputTypeScript(create)).toContain('role?: "admin" | "member"') + expect(outputTypeScript(search)).toBe("string") + expect(outputTypeScript(remove)).toBe("null") + + const result = await Effect.runPromise( + CodeMode.make({ tools: { api: api.tools } }) + .execute( + ` + const user = await tools.api.users.get({ + userId: "user-1", + include: ["profile", "permissions"], + verbose: true, + "X-Trace-ID": "trace-1", + }) + const created = await tools.api.users.create({ + name: "Grace", + email: "grace@example.test", + role: "admin", + }) + const summary = await tools.api.search.run({ + filter: { query: "effect", page: 2 }, + tags: ["typescript", "runtime"], + }) + const removed = await tools.api.users.remove({ userId: "user-1" }) + return { user, created, summary, removed } + `, + ) + .pipe(Effect.provide(client.layer)), + ) + + expect(result).toMatchObject({ + ok: true, + value: { + user: { id: "user-1", name: "Ada" }, + created: { id: "user-2", name: "Grace" }, + summary: "2 matches", + removed: null, + }, + }) + expect(resolutions).toEqual(["BearerAuth", "ApiKey", "BearerAuth"]) + expect(client.requests).toHaveLength(4) + + const getUrl = new URL(client.requests[0]!.url) + expect(getUrl.pathname).toBe("/users/user-1") + expect(getUrl.searchParams.get("include")).toBe("profile,permissions") + expect(getUrl.searchParams.get("verbose")).toBe("true") + expect(client.requests[0]!.headers["x-trace-id"]).toBe("trace-1") + expect(client.requests[0]!.headers.authorization).toBe("Bearer bearer-secret") + + const createUrl = new URL(client.requests[1]!.url) + expect(createUrl.searchParams.get("api_key")).toBe("api-secret") + expect(client.requests[1]!.body).toEqual({ name: "Grace", email: "grace@example.test", role: "admin" }) + + const searchUrl = new URL(client.requests[2]!.url) + expect(searchUrl.searchParams.get("filter[query]")).toBe("effect") + expect(searchUrl.searchParams.get("filter[page]")).toBe("2") + expect(searchUrl.searchParams.getAll("tags")).toEqual(["typescript", "runtime"]) + expect(client.requests[2]!.headers.authorization).toBeUndefined() + expect(new URL(client.requests[3]!.url).pathname).toBe("/users/user-1") + expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret") + }) + + test("converts representative opencode operations into the expected tool shape", async () => { + const spec = await opencodeSpec() + const result = OpenAPI.fromSpec({ spec, baseUrl }) + + expect(result.skipped).toHaveLength(5) + expect(result.skipped).toContainEqual({ + method: "GET", + path: "/api/pty/{ptyID}/connect", + reason: "WebSocket operations are not supported", + }) + expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3) + expect(result.skipped).toContainEqual({ + method: "GET", + path: "/api/fs/read/*", + reason: "binary responses are not supported", + }) + expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined() + expect(toolAt(result.tools, "v2.session.get")).not.toBeUndefined() + expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined() + + const sessionGet = toolAt(result.tools, "v2.session.get") + expect(Tool.isDefinition(sessionGet)).toBe(true) + if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated") + expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }") + expect(outputTypeScript(sessionGet)).toContain("id: string") + expect(outputTypeScript(sessionGet)).toContain("additions: number") + + const switchAgent = toolAt(result.tools, "v2.session.switchAgent") + expect(Tool.isDefinition(switchAgent)).toBe(true) + if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated") + expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }") + + const contextEntryPut = toolAt(result.tools, "v2.session.contextEntry.put") + expect(Tool.isDefinition(contextEntryPut)).toBe(true) + if (!Tool.isDefinition(contextEntryPut)) throw new Error("v2.session.contextEntry.put was not generated") + expect(inputTypeScript(contextEntryPut)).toBe("{ sessionID: string; key: string; value: unknown }") + expect(toolAt(result.tools, "v2_session_context_entry_put_2")).toBeUndefined() + expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined() + expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() + expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() + expect(toolAt(result.tools, "v2.event.changes")).toBeUndefined() + expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() + expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined() + }) + + test("preserves operation path sanitization and collision handling", () => { + const response = { responses: { 200: { description: "Success" } } } + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/first": { get: { ...response, operationId: "group.item" } }, + "/second": { get: { ...response, operationId: "group.item" } }, + "/third": { get: { ...response, operationId: "group..other" } }, + }, + }, + }) + + expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true) + expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true) + expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true) + }) + + test("synthesizes flat operation IDs from methods and paths", () => { + const response = { responses: { 200: { description: "Success" } } } + const tools = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/users": { get: response, post: response }, + "/users/{id}": { get: response, patch: response, delete: response }, + "/organizations/{organizationId}/users/{id}": { get: response }, + }, + }, + }).tools + + for (const path of [ + "getUsers", + "postUsers", + "getUsersById", + "patchUsersById", + "deleteUsersById", + "getOrganizationsByOrganizationidUsersById", + ]) { + expect(Tool.isDefinition(toolAt(tools, path))).toBe(true) + } + }) + + test("lets operation parameters override matching path parameters", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + parameters: [{ name: "limit", in: "query", schema: { type: "string" } }], + get: { + operationId: "test", + parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }], + responses: { 200: { description: "Success" } }, + }, + }, + }, + }, + }).tools, + "test", + ) + + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + expect(inputTypeScript(tool)).toBe("{ limit: number }") + }) + + test("normalizes OpenAPI 3.0 schemas with Effect", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.0.3", + paths: { + "/search": { + get: { + operationId: "search", + parameters: [ + { + in: "query", + name: "value", + schema: { type: "string", nullable: true, minLength: 2 }, + }, + ], + responses: { 200: { description: "Success" } }, + }, + }, + }, + }, + }) + const search = toolAt(result.tools, "search") + + expect(Tool.isDefinition(search)).toBe(true) + if (!Tool.isDefinition(search)) throw new Error("search was not generated") + expect(inputTypeScript(search)).toBe("{ value?: string | null }") + const schema: unknown = search.input + const input = isRecord(schema) ? schema : {} + const properties = isRecord(input.properties) ? input.properties : {} + const value = isRecord(properties.value) ? properties.value : {} + expect(value.minLength).toBe(2) + }) + + test("preserves schema-local definitions alongside component definitions", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + get: { + operationId: "test", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: { $ref: "#/$defs/Local", $defs: { Local: { type: "string" } } }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: { Global: { type: "number" } } }, + }, + }).tools, + "test", + ) + + if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated") + expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } }) + }) + + test("documents that the opencode fixture is unauthenticated", async () => { + const spec = await opencodeSpec() + const components = isRecord(spec.components) ? spec.components : {} + const result = OpenAPI.fromSpec({ spec, baseUrl }) + + expect(spec.security).toStrictEqual([]) + expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([]) + const health = toolAt(result.tools, "v2.health.get") + const healthInput = isRecord(health) ? health.input : undefined + expect(healthInput).toMatchObject({ type: "object", properties: {} }) + const input = isRecord(healthInput) ? healthInput : {} + expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([]) + }) + + test("exposes real opencode operations through CodeMode discovery", async () => { + const { layer } = recordingClient(() => json({})) + const runtime = CodeMode.make({ + tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }, + }) + const result = await Effect.runPromise( + runtime + .execute( + ` + return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 }) + `, + ) + .pipe(Effect.provide(layer)), + ) + + expect(result).toMatchObject({ ok: true }) + if (!result.ok) return + expect(result.value).toMatchObject({ + items: [ + { + path: "tools.opencode.v2.health.get", + description: "Check whether the API server is ready to accept requests.", + }, + ], + }) + expect(JSON.stringify(result.value)).toContain("healthy: true") + }) + + test("invokes real opencode path parameters and JSON request bodies", async () => { + const { requests, layer } = recordingClient((request) => { + if (request.method === "GET") return json({ id: "ses_123" }) + return json({ id: "ses_456" }) + }) + const runtime = CodeMode.make({ + tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }, + }) + + const result = await Effect.runPromise( + runtime + .execute( + ` + const existing = await tools.opencode.v2.session.get({ sessionID: "ses_123" }) + const created = await tools.opencode.v2.session.create({ id: "ses_456" }) + return { existing, created } + `, + ) + .pipe(Effect.provide(layer)), + ) + + expect(result).toMatchObject({ ok: true }) + expect(requests).toHaveLength(2) + expect(requests[0]).toMatchObject({ method: "GET", body: undefined }) + expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123") + expect(requests[1]).toMatchObject({ + method: "POST", + url: "http://localhost:4096/api/session", + body: { id: "ses_456" }, + }) + }) + + test("serializes deep-object query parameters from the opencode fixture", async () => { + const client = recordingClient(() => json({ directory: "/tmp" })) + const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get") + if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated") + + await Effect.runPromise( + location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)), + ) + + const url = new URL(client.requests[0]!.url) + expect(url.searchParams.get("location[directory]")).toBe("/tmp") + expect(url.searchParams.get("location[workspace]")).toBe("workspace-1") + }) + + test("serializes supported simple and form parameter shapes", async () => { + const client = recordingClient(() => json({ ok: true })) + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/items/{keys}": { + get: { + operationId: "items", + parameters: [ + { name: "keys", in: "path", required: true, schema: { type: "array", items: { type: "string" } } }, + { name: "tags", in: "query", style: "form", explode: false, schema: { type: "array" } }, + { name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } }, + { name: "nullable", in: "query", required: true, schema: { type: ["string", "null"] } }, + { name: "constructor", in: "query", schema: { type: "string" } }, + { name: "meta", in: "header", style: "simple", explode: true, schema: { type: "object" } }, + ], + responses: { 200: { description: "Success" } }, + }, + }, + }, + }, + }) + const tool = toolAt(result.tools, "items") + if (!Tool.isDefinition(tool)) throw new Error("items was not generated") + + await Effect.runPromise( + tool + .run({ + keys: ["a!", "b*"], + tags: ["x", "y"], + filter: { state: "open", page: 2 }, + nullable: null, + constructor_2: "safe", + meta: { a: "b", c: "d" }, + }) + .pipe(Effect.provide(client.layer)), + ) + + const url = new URL(client.requests[0]!.url) + expect(url.pathname).toBe("/items/a%21,b%2A") + expect(url.searchParams.get("tags")).toBe("x,y") + expect(url.searchParams.get("state")).toBe("open") + expect(url.searchParams.get("page")).toBe("2") + expect(url.searchParams.get("nullable")).toBe("null") + expect(url.searchParams.get("constructor")).toBe("safe") + expect(client.requests[0]!.headers.meta).toBe("a=b,c=d") + await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + "unsupported nested value", + ) + }) + + test("skips unsupported parameter encodings and malformed security", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + security: [{ bearer: [] }], + paths: { + "/cookie": { + get: { + operationId: "cookie", + parameters: [{ name: "session", in: "cookie", schema: { type: "string" } }], + responses: { 200: { description: "Success" } }, + }, + }, + "/reserved": { + get: { + operationId: "reserved", + parameters: [{ name: "query", in: "query", allowReserved: true, schema: { type: "string" } }], + responses: { 200: { description: "Success" } }, + }, + }, + "/invalid-style": { + get: { + operationId: "invalidStyle", + parameters: [{ name: "query", in: "query", style: 42, schema: { type: "string" } }], + responses: { 200: { description: "Success" } }, + }, + }, + "/security": { + get: { operationId: "security", security: null, responses: { 200: { description: "Success" } } }, + }, + }, + }, + }) + + expect(result.tools).toEqual({}) + expect(result.skipped.map((item) => item.reason)).toEqual([ + "cookie parameter 'session' is not supported", + "parameter 'query' uses unsupported allowReserved encoding", + "parameter 'query' has an invalid style", + "security declaration is not an array", + ]) + }) + + test("fails closed on prototype-named missing security schemes", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation({ security: [JSON.parse('{"__proto__":[]}')] }), + }) + + expect(result.tools).toEqual({}) + expect(result.skipped[0]?.reason).toBe("security requirement references missing or malformed scheme: __proto__") + }) + + test("resolves bearer authentication without exposing it as input", async () => { + const contexts: Array[0]> = [] + const client = recordingClient(() => json({ ok: true })) + const spec = { + ...singleOperation({ operationId: undefined }), + security: [{ bearer: [] }], + components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } }, + } satisfies Document + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec, + auth: { + resolve: (context) => { + contexts.push(context) + return Effect.succeed({ type: "bearer", token: "secret" }) + }, + }, + }).tools, + "getTest", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer))) + + expect(inputTypeScript(tool)).toBe("{}") + expect(client.requests[0]!.headers.authorization).toBe("Bearer secret") + expect(contexts).toEqual([ + { + name: "bearer", + definition: { type: "http", scheme: "bearer" }, + scopes: [], + operation: { + operationId: undefined, + method: "GET", + path: "/test", + summary: undefined, + description: undefined, + }, + }, + ]) + }) + + test("applies authentication carriers without prototype or collision loss", async () => { + const client = recordingClient(() => json({ ok: true })) + const authenticated = ( + security: ReadonlyArray>>, + schemes: Record, + ) => + OpenAPI.fromSpec({ + baseUrl, + spec: { ...singleOperation({}), security, components: { securitySchemes: schemes } }, + auth: { resolve: () => Effect.succeed({ type: "apiKey", value: "secret" }) }, + }) + const prototype = toolAt( + authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools, + "test", + ) + if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated") + + await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer))) + expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret") + + const duplicate = toolAt( + authenticated([{ first: [], second: [] }], { + first: { type: "apiKey", in: "header", name: "x-key" }, + second: { type: "apiKey", in: "header", name: "x-key" }, + }).tools, + "test", + ) + if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated") + await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow( + "multiple credentials", + ) + + const cookie = authenticated([{ key: [] }], { key: { type: "apiKey", in: "cookie", name: "session" } }) + expect(cookie.tools).toEqual({}) + expect(cookie.skipped[0]?.reason).toBe("cookie authentication 'key' is not supported") + + const alternative = OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation({}), + security: [{ cookie: [] }, { bearer: [] }], + components: { + securitySchemes: { + cookie: { type: "apiKey", in: "cookie", name: "session" }, + bearer: { type: "http", scheme: "bearer" }, + }, + }, + }, + auth: { + resolve: ({ name }) => Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined), + }, + }) + const alternativeTool = toolAt(alternative.tools, "test") + if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated") + await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer))) + expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret") + }) + + test("honors server precedence and rejects ambiguous base URLs", async () => { + const client = recordingClient(() => json({ ok: true })) + const spec = { + ...singleOperation({ servers: [{ url: "https://operation.example/v1" }] }), + servers: [{ url: "https://document.example" }], + } satisfies Document + const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test") + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer))) + expect(client.requests[0]?.url).toBe("https://operation.example/v1/test") + + const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" }) + expect(invalid.tools).toEqual({}) + expect(invalid.skipped[0]?.reason).toContain("unsupported query string or fragment") + + const malformed = OpenAPI.fromSpec({ spec, baseUrl: "https:/example.com" }) + expect(malformed.tools).toEqual({}) + expect(malformed.skipped[0]?.reason).toContain("not an absolute HTTP(S) URL") + }) + + test("resolves chained response refs before detecting unsupported transports", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation({ responses: { 200: { $ref: "#/components/responses/First" } } }), + components: { + responses: { + First: { $ref: "#/components/responses/Stream" }, + Stream: { content: { "text/event-stream": { schema: { type: "string" } } } }, + }, + }, + }, + }) + + expect(result.tools).toEqual({}) + expect(result.skipped[0]?.reason).toBe("SSE operations are not supported") + }) + + test("resolves response schemas before detecting binary output", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation({ + responses: { + 200: { + content: { "text/plain": { schema: { $ref: "#/components/schemas/File" } } }, + }, + }, + }), + components: { schemas: { File: { type: "string", format: "binary" } } }, + }, + }) + + expect(result.tools).toEqual({}) + expect(result.skipped[0]?.reason).toBe("binary responses are not supported") + }) + + test("validates composite parameters before resolving auth", async () => { + const resolutions: Array = [] + const client = recordingClient(() => json({ ok: true })) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation({ + parameters: [{ name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } }], + }), + security: [{ bearer: [] }], + components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } }, + }, + auth: { + resolve: ({ name }) => { + resolutions.push(name) + return Effect.succeed({ type: "bearer", token: "secret" }) + }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await expect( + Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))), + ).rejects.toThrow("unsupported nested value") + expect(resolutions).toEqual([]) + expect(client.requests).toEqual([]) + }) + + test("preserves JSON media types and rejects unencodable bodies", async () => { + const client = recordingClient(() => json({ ok: true })) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { "application/merge-patch+json": { schema: { type: "object" } } }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer))) + expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json") + const cyclic: Record = {} + cyclic.self = cyclic + await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + "Invalid JSON body", + ) + }) + + test("rejects oversized and malformed JSON responses", async () => { + const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test") + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + const oversized = recordingClient( + () => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }), + ) + const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } })) + const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1))) + + await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow( + "response exceeds 50 MiB", + ) + await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow( + "returned malformed JSON", + ) + await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow( + "response exceeds 50 MiB", + ) + }) + + test("keeps non-JSON responses raw and unions every success output", async () => { + const spec = singleOperation({ + responses: { + 200: { description: "Text", content: { "text/plain": { schema: { type: "string" } } } }, + 204: { description: "Empty" }, + }, + }) + const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test") + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } })) + + expect(outputTypeScript(tool)).toBe("string | null") + await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123") + }) + + test("fails missing required parameters before auth and network", async () => { + const { requests, layer } = recordingClient(() => json({})) + const runtime = CodeMode.make({ + tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }, + }) + + const result = await Effect.runPromise( + runtime.execute("return await tools.opencode.v2.session.get({})").pipe(Effect.provide(layer)), + ) + + expect(result).toMatchObject({ ok: false }) + expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'") + expect(requests).toHaveLength(0) + }) + + test("prefixes cross-location collisions and reconstructs the HTTP request", async () => { + const spec = { + openapi: "3.1.0", + info: { title: "collision", version: "1.0.0" }, + paths: { + "/echo": { + post: { + operationId: "echo", + requestBody: { + required: true, + content: { "application/json": { schema: { type: "string" } } }, + }, + responses: { "204": { description: "Echoed" } }, + }, + }, + "/things/{id}": { + post: { + operationId: "things.update", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { name: "id", in: "query", required: true, schema: { type: "string" } }, + { name: "path_id", in: "query", schema: { type: "string" } }, + { name: "id", in: "header", required: true, schema: { type: "string" } }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: false, + }, + }, + }, + }, + responses: { "204": { description: "Updated" } }, + }, + }, + }, + } satisfies Document + const { requests, layer } = recordingClient(() => new Response(null, { status: 204 })) + const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools + const update = toolAt(tools, "things.update") + const echo = toolAt(tools, "echo") + + expect(Tool.isDefinition(update)).toBe(true) + if (!Tool.isDefinition(update)) throw new Error("things.update was not generated") + expect(inputTypeScript(update)).toBe( + "{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }", + ) + expect(Tool.isDefinition(echo)).toBe(true) + if (!Tool.isDefinition(echo)) throw new Error("echo was not generated") + expect(inputTypeScript(echo)).toBe("{ body: string }") + + const runtime = CodeMode.make({ tools }) + const result = await Effect.runPromise( + runtime + .execute( + ` + const updated = await tools.things.update({ path_id: "path", query_id: "query", path_id_2: "literal", header_id: "header", body_id: "body" }) + const echoed = await tools.echo({ body: "hello" }) + return { updated, echoed } + `, + ) + .pipe(Effect.provide(layer)), + ) + + expect(result).toMatchObject({ ok: true }) + expect(requests).toHaveLength(2) + expect(new URL(requests[0]!.url).pathname).toBe("/things/path") + expect(new URL(requests[0]!.url).searchParams.get("id")).toBe("query") + expect(new URL(requests[0]!.url).searchParams.get("path_id")).toBe("literal") + expect(requests[0]!.headers.id).toBe("header") + expect(requests[0]!.body).toStrictEqual({ id: "body" }) + expect(requests[1]!.body).toBe("hello") + }) + + test("keeps bodies nested when flattening would lose schema semantics", () => { + const body = (schema: Record, required = true) => ({ + required, + content: { "application/json": { schema } }, + }) + const spec = { + openapi: "3.1.0", + info: { title: "bodies", version: "1.0.0" }, + paths: Object.fromEntries( + [ + [ + "optional", + body( + { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + false, + ), + ], + ["dictionary", body({ type: "object", additionalProperties: { type: "string" } })], + [ + "composed", + body({ + type: "object", + allOf: [{ type: "object", properties: { name: { type: "string" } }, required: ["name"] }], + additionalProperties: false, + }), + ], + [ + "nullable", + body({ + type: ["object", "null"], + properties: { name: { type: "string" } }, + additionalProperties: false, + }), + ], + ].map(([name, requestBody]) => [ + `/body/${name}`, + { + post: { + operationId: `body.${name}`, + requestBody, + responses: { "204": { description: "Accepted" } }, + }, + }, + ]), + ), + } satisfies Document + const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools + + for (const name of ["optional", "dictionary", "composed", "nullable"]) { + const tool = toolAt(tools, `body.${name}`) + expect(Tool.isDefinition(tool)).toBe(true) + if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`) + const input = isRecord(tool.input) ? tool.input : {} + expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"]) + } + const optional = toolAt(tools, "body.optional") + if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated") + expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }") + }) +}) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfa85831837cf7a8cea70a57901b061d3fb6ad1e --- /dev/null +++ b/packages/codemode/test/parity.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" +import { ToolRuntime } from "../src/tool-runtime.js" + +// Runs a CodeMode program with no host tools and returns the CodeMode.Result. These tests pin the +// JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where +// a strict interpreter would throw but idiomatic JS yields undefined / succeeds. +// +// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when +// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox +// `undefined` read check `=== undefined` inside the program and `null` at the boundary. +const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("H2: string property access reads as undefined (not a throw)", () => { + test("unknown property on a string is undefined", async () => { + expect(await value(`const s = "hi"; return s.login === undefined`)).toBe(true) + expect(await value(`const s = "hi"; return s.login`)).toBeNull() + }) + + test("optional chaining + fallback on a string does not throw", async () => { + expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback") + }) + + test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => { + // me.result is a string; me.result?.login is undefined, so we fall back to the raw string. + expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe( + '{"login":"x"}', + ) + }) + + test("unknown property on a number is undefined", async () => { + expect(await value(`return (5).foo ?? "n"`)).toBe("n") + }) + + test("supported string methods still work", async () => { + expect(await value(`return "AB".toLowerCase()`)).toBe("ab") + expect(await value(`return "hello".length`)).toBe(5) + }) +}) + +describe("H3: array property access reads as undefined (not a throw)", () => { + test("unknown property on an array is undefined", async () => { + expect(await value(`return [1,2,3].foo === undefined`)).toBe(true) + expect(await value(`return [1,2,3].foo`)).toBeNull() + }) + + test("optional chaining on an array does not throw", async () => { + expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb") + }) + + test("unknown property reads stay undefined for methods CodeMode does not implement", async () => { + expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true) + }) + + test("supported array methods and indexing still work", async () => { + expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4]) + expect(await value(`return [1,2,3][9] === undefined`)).toBe(true) + expect(await value(`return [1,2,3][9]`)).toBeNull() + }) +}) + +describe("H6: object spread of null/undefined is a no-op", () => { + test("spreading null is a no-op", async () => { + expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 }) + }) + + test("spreading an absent argument merges cleanly", async () => { + expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 }) + }) + + test("spreading a real object still works", async () => { + expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 }) + }) + + test("spreading an array into an object still errors", async () => { + const err = await error(`return { ...[1,2], a: 1 }`) + expect(err.kind).toBe("InvalidDataValue") + }) +}) + +describe("H4: typeof on an undeclared identifier is 'undefined'", () => { + test("feature-detection guard does not throw", async () => { + expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe") + }) + + test("typeof of a declared binding is unaffected", async () => { + expect(await value(`const x = 5; return typeof x`)).toBe("number") + expect(await value(`const s = "a"; return typeof s`)).toBe("string") + }) + + test("referencing an undeclared identifier outside typeof still throws", async () => { + const err = await error(`return foo + 1`) + expect(err.message).toContain("foo") + }) +}) + +describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => { + test("guards run instead of the program crashing on a transient NaN", async () => { + expect(await value(`return parseInt("abc") || 0`)).toBe(0) + expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0) + expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1) + // average of an empty list, guarded - the classic divide-by-zero that used to throw pre-guard + expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0) + }) + + test("a non-finite value becomes null when it leaves the sandbox", async () => { + expect(await value(`return 5/0`)).toBeNull() + expect(await value(`return 0/0`)).toBeNull() + expect(await value(`return Math.max()`)).toBeNull() + // nested, too - normalization walks the returned structure + expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] }) + }) + + test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => { + expect(await value(`return Number.isNaN(NaN)`)).toBe(true) + expect(await value(`return Infinity > 1e9`)).toBe(true) + expect(await value(`return Number.isFinite(1/0)`)).toBe(false) + expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3) + // JSON.stringify inside the sandbox matches JS: non-finite serializes to null + expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}') + }) + + test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => { + // Tool-call arguments funnel through copyOut too, so this one function pins both boundaries. + expect(ToolRuntime.copyOut(NaN)).toBeNull() + expect(ToolRuntime.copyOut(Infinity)).toBeNull() + expect(ToolRuntime.copyOut(-Infinity)).toBeNull() + expect(ToolRuntime.copyOut(42)).toBe(42) + expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] }) + }) +}) + +describe("Error values and instanceof", () => { + test("new Error carries name/message and is instanceof Error", async () => { + expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([ + true, + "Error", + "boom", + ]) + }) + + test("Error without new behaves like new Error", async () => { + expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([ + true, + "Error", + "plain", + ]) + expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([ + "Error", + "", + true, + ]) + }) + + test("specific error types are instanceof themselves and Error, not each other", async () => { + expect( + await value( + `const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`, + ), + ).toEqual([true, true, false]) + expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false) + }) + + test("thrown errors keep instanceof through try/catch", async () => { + expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([ + true, + "x", + ]) + }) + + test("interpreter runtime failures are caught as Error values", async () => { + expect(await value(`try { JSON.parse("nope") } catch (e) { return e instanceof Error }`)).toBe(true) + expect(await value(`try { undeclared() } catch (e) { return e instanceof Error }`)).toBe(true) + }) + + test("caught failures carry the constructor name the real-JS failure would have", async () => { + // JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the + // message keeps the engine's position detail. + expect( + await value(` + try { JSON.parse("{oops") } catch (e) { + return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")] + } + `), + ).toEqual(["SyntaxError", true, true, false, true]) + expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([ + "ReferenceError", + true, + ]) + expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([ + "TypeError", + true, + ]) + expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual( + ["RangeError", true], + ) + expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([ + "SyntaxError", + true, + ]) + expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([ + "SyntaxError", + true, + ]) + }) + + test("diagnostics without a specific real-JS analogue are named plain Error", async () => { + expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([ + "Error", + true, + ]) + }) + + test("Promise.allSettled rejection reasons are Error values", async () => { + expect( + await value(` + const settled = await Promise.allSettled([Promise.reject(new Error("b"))]) + return [settled[0].reason instanceof Error, settled[0].reason.message] + `), + ).toEqual([true, "b"]) + }) + + test("non-error thrown values are not instanceof Error", async () => { + expect(await value(`try { throw "raw" } catch (e) { return e instanceof Error }`)).toBe(false) + expect(await value(`try { throw { message: "shaped" } } catch (e) { return e instanceof Error }`)).toBe(false) + }) + + test("plain data is never instanceof Error", async () => { + expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([ + false, + false, + false, + ]) + }) + + test("error values still serialize as plain { name, message } data", async () => { + expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" }) + expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}') + expect(await value(`try { throw new Error("m") } catch (e) { return Object.keys(e) }`)).toEqual(["name", "message"]) + }) + + test("spreading an error loses the brand, like losing the prototype in JS", async () => { + expect(await value(`const e = new Error("m"); return ({ ...e }) instanceof Error`)).toBe(false) + expect(await value(`const e = new Error("m"); return { ...e }`)).toEqual({ name: "Error", message: "m" }) + }) + + test("typeof Error is function; an unknown instanceof right-hand side is a catchable error", async () => { + expect(await value(`return typeof Error`)).toBe("function") + expect(await value(`try { return 1 instanceof 5 } catch (e) { return "caught" }`)).toBe("caught") + const err = await error(`return 1 instanceof 5`) + expect(err.message).toContain("right-hand side of 'instanceof'") + }) +}) + +describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { + test("splice removes in place and returns the removed elements", async () => { + expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ + removed: [2, 3], + a: [1, 4], + }) + }) + + test("splice inserts new elements at the cut", async () => { + expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"]) + expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ + removed: [2], + a: [1, "x", 3], + }) + }) + + test("splice with one argument removes to the end; negative start counts back", async () => { + expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ + removed: [2, 3], + a: [1], + }) + expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ + removed: [3], + a: [1, 2], + }) + }) + + test("splice rejects inserting a container into itself", async () => { + const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`) + expect(err.kind).toBe("InvalidDataValue") + expect(err.message).toContain("circular") + }) + + test("fill overwrites a range and returns the mutated array", async () => { + expect(await value(`const a = [1,2,3,4]; return a.fill(0, 1, 3)`)).toEqual([1, 0, 0, 4]) + expect(await value(`return [1,2,3].fill("z")`)).toEqual(["z", "z", "z"]) + }) + + test("copyWithin copies a range in place", async () => { + expect(await value(`return [1,2,3,4,5].copyWithin(0, 3)`)).toEqual([4, 5, 3, 4, 5]) + }) + + test("keys/values/entries return arrays usable with for...of and spread", async () => { + expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2]) + expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"]) + expect( + await value(` + const out = [] + for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item) + return out + `), + ).toEqual(["0:a", "1:b"]) + expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]]) + }) +}) + +describe("string methods: localeCompare, normalize, trim aliases", () => { + test("localeCompare orders strings for sorting", async () => { + expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"]) + expect(await value(`return "a".localeCompare("a")`)).toBe(0) + }) + + test("normalize applies unicode normalization forms", async () => { + expect(await value(`return "\\u0065\\u0301".normalize("NFC").length`)).toBe(1) + expect(await value(`return "\\u00e9".normalize("NFD").length`)).toBe(2) + expect(await value(`return "x".normalize() === "x"`)).toBe(true) + }) + + test("an invalid normalize form is a clear catchable error", async () => { + expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"') + }) + + test("trimLeft/trimRight alias trimStart/trimEnd", async () => { + expect(await value(`return " x ".trimLeft()`)).toBe("x ") + expect(await value(`return " x ".trimRight()`)).toBe(" x") + }) +}) + +describe("compound assignment matches its binary operator", () => { + // `x op= y` must behave exactly like `x = x op y`, sharing the binary operator's coercion + // semantics (Dates string-coerce for `+` and use their time value for arithmetic; data + // objects/arrays coerce to their JS string form). + const pair = async (compound: string, expanded: string) => { + const [a, b] = await Promise.all([value(compound), value(expanded)]) + expect(a).toEqual(b) + return a + } + + test("sandbox Date += concatenates its string form, like d = d + 1", async () => { + const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`) + expect(result).toBe("1970-01-01T00:00:01.000Z1") + }) + + test("sandbox Date numeric compound ops use its time value", async () => { + expect( + await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`), + ).toBe(600) + expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe( + 250, + ) + }) + + test("string += object/array matches x = x + obj", async () => { + expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe( + "a[object Object]", + ) + expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2") + }) + + test("compound assignment through a member target coerces the same way", async () => { + expect( + await pair( + `const o = { s: "t" }; o.s += new Date(0); return o.s`, + `const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`, + ), + ).toBe("t1970-01-01T00:00:00.000Z") + }) + + test("numeric and string compound operators sweep identically to their expansions", async () => { + const cases: Array<[string, number | string]> = [ + [`let x = 7; x += 3; return x`, 7 + 3], + [`let x = 7; x -= 3; return x`, 7 - 3], + [`let x = 7; x *= 3; return x`, 7 * 3], + [`let x = 7; x /= 2; return x`, 7 / 2], + [`let x = 7; x %= 3; return x`, 7 % 3], + [`let x = 7; x **= 2; return x`, 7 ** 2], + [`let x = 7; x &= 3; return x`, 7 & 3], + [`let x = 7; x |= 8; return x`, 7 | 8], + [`let x = 7; x ^= 2; return x`, 7 ^ 2], + [`let x = 7; x <<= 2; return x`, 7 << 2], + [`let x = -7; x >>= 1; return x`, -7 >> 1], + [`let x = -7; x >>>= 1; return x`, -7 >>> 1], + [`let x = "a"; x += "b"; return x`, "ab"], + ] + for (const [compound, expected] of cases) { + expect(await value(compound)).toBe(expected) + expect(await value(compound.replace(/x (\S+)= /, (_, op) => `x = x ${op} `))).toBe(expected) + } + }) +}) + +describe("H5: builtin coercion functions work as array callbacks", () => { + test("filter(Boolean) drops falsy values", async () => { + expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3]) + }) + + test("map(String) coerces each element", async () => { + expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"]) + }) + + test("arrow callbacks still work (no regression)", async () => { + expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4]) + expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6) + }) + + test("a non-callable callback is still rejected", async () => { + const err = await error(`return [1,2,3].map(42)`) + expect(err.message).toContain("callback") + }) +}) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..545d463abfac49288a138251f55cbd9eb01ed56a --- /dev/null +++ b/packages/codemode/test/promise.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool, toolError } from "../src/index.js" + +// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on +// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are +// ordinary functions over arbitrary arrays mixing promises and plain values. + +type Trace = { + starts: Array + active: number + maxActive: number + completed: number + interrupted: number +} + +const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 }) + +/** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */ +const sleepyTool = (trace: Trace) => + Tool.make({ + description: "Echo an id after a delay", + input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }), + output: Schema.Number, + run: ({ id, ms }) => + Effect.gen(function* () { + trace.starts.push(id) + trace.active += 1 + trace.maxActive = Math.max(trace.maxActive, trace.active) + yield* Effect.sleep(ms ?? 20) + trace.active -= 1 + trace.completed += 1 + return id + }).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + trace.active -= 1 + trace.interrupted += 1 + }), + ), + ), + }) + +const failingTool = Tool.make({ + description: "Always refuse", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Lookup refused")), +}) + +const run = ( + code: string, + options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}, +): Promise => { + const trace = options.trace ?? makeTrace() + return Effect.runPromise( + CodeMode.execute({ + tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, + code, + ...(options.limits ? { limits: options.limits } : {}), + }), + ) +} + +const value = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => { + const result = await run(code, options) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => { + const result = await run(code, options) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("first-class promise values", () => { + test("an un-awaited tool call starts eagerly, in call order, before any await", async () => { + const trace = makeTrace() + const result = await value( + ` + const a = tools.host.sleepy({ id: 1, ms: 40 }) + const b = tools.host.sleepy({ id: 2, ms: 40 }) + const rb = await b + const ra = await a + return [ra, rb] + `, + { trace }, + ) + expect(result).toEqual([1, 2]) + expect(trace.starts).toEqual([1, 2]) + // Both calls overlapped even though they were awaited sequentially. + expect(trace.maxActive).toBeGreaterThan(1) + }) + + test("awaiting the same promise twice settles once and never re-runs the call", async () => { + const result = await run(` + const p = tools.host.sleepy({ id: 7 }) + const x = await p + const y = await p + return [x, y] + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toEqual([7, 7]) + expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }]) + }) + + test("await of a non-promise value is a passthrough no-op", async () => { + expect(await value(`return await 42`)).toBe(42) + expect(await value(`const x = await "s"; return x`)).toBe("s") + expect(await value(`return await null`)).toBeNull() + expect(await value(`return (await [1, 2]).length`)).toBe(2) + }) + + test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => { + expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9) + }) + + test("typeof a promise is 'object', and console.log renders it sensibly", async () => { + const result = await run(` + const p = Promise.resolve(1) + console.log(p) + return typeof p + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("object") + expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"]) + }) + + test("an awaited failure is catchable exactly like a synchronous throw", async () => { + expect( + await value(` + const p = tools.host.fail({}) + try { + await p + return "no" + } catch (e) { + return e.message + } + `), + ).toBe("Lookup refused") + }) + + test("a fire-and-forget call completes before the execution ends", async () => { + const trace = makeTrace() + const result = await value( + ` + tools.host.sleepy({ id: 1, ms: 30 }) + return "done" + `, + { trace }, + ) + expect(result).toBe("done") + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + + test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => { + const diagnostic = await error(` + tools.host.fail({}) + return "done" + `) + expect(diagnostic.kind).toBe("ToolFailure") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call") + expect(diagnostic.message).toContain("Lookup refused") + expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)") + }) +}) + +describe("promises at data boundaries", () => { + test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => { + const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + expect(diagnostic.message).toContain("await tools.ns.tool(...)") + }) + + test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => { + const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + + test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => { + const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + + test("operators reject promise operands", async () => { + const diagnostic = await error(`return Promise.resolve(1) + 1`) + expect(diagnostic.kind).toBe("InvalidDataValue") + }) +}) + +describe("Promise.all over arbitrary arrays", () => { + test("mixes promises and plain values, preserving order", async () => { + expect( + await value(` + return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42]) + `), + ).toEqual([1, "plain", 2, 42]) + }) + + test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => { + expect( + await value(` + const calls = [] + calls.push(tools.host.sleepy({ id: 1 })) + calls.push(7) + const more = [tools.host.sleepy({ id: 2 })] + const batch = [...calls, ...more, "x"] + return await Promise.all(batch) + `), + ).toEqual([1, 7, 2, "x"]) + }) + + test("runs items.map tool calls in parallel", async () => { + const trace = makeTrace() + const result = await value( + ` + const ids = [1, 2, 3, 4] + return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 }))) + `, + { trace }, + ) + expect(result).toEqual([1, 2, 3, 4]) + // maxActive counts truly-overlapping live executions, so > 1 proves real + // parallelism deterministically - no wall-clock assertion needed. + expect(trace.maxActive).toBeGreaterThan(1) + }) + + test("caps live tool-call concurrency at the fixed internal constant (8)", async () => { + const trace = makeTrace() + const result = await value( + ` + const ids = [] + for (let i = 0; i < 20; i += 1) ids.push(i) + const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 }))) + return results.length + `, + { trace }, + ) + expect(result).toBe(20) + expect(trace.maxActive).toBeGreaterThan(1) + expect(trace.maxActive).toBeLessThanOrEqual(8) + }) + + test("resolves the empty array", async () => { + expect(await value(`return await Promise.all([])`)).toEqual([]) + }) + + test("rejects with the first failure, catchable in-program", async () => { + expect( + await value(` + try { + await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})]) + return "no" + } catch (e) { + return e.message + } + `), + ).toBe("Lookup refused") + }) + + test("a non-collection argument is a clear error", async () => { + const diagnostic = await error(`return await Promise.all(42)`) + expect(diagnostic.message).toContain("Promise.all expects an array") + }) + + test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => { + const diagnostic = await error( + `return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`, + { limits: { maxToolCalls: 2 } }, + ) + expect(diagnostic.kind).toBe("ToolCallLimitExceeded") + }) +}) + +describe("Promise.allSettled", () => { + test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => { + expect( + await value(` + return await Promise.allSettled([ + tools.host.sleepy({ id: 5 }), + tools.host.fail({}), + "plain", + Promise.reject(new Error("boom")), + ]) + `), + ).toEqual([ + { status: "fulfilled", value: 5 }, + { status: "rejected", reason: { name: "Error", message: "Lookup refused" } }, + { status: "fulfilled", value: "plain" }, + { status: "rejected", reason: { name: "Error", message: "boom" } }, + ]) + }) + + test("never rejects for program-level failures", async () => { + const result = await run(` + const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})]) + return settled.filter((s) => s.status === "rejected").length + `) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toBe(2) + }) +}) + +describe("Promise.race", () => { + test("first settlement wins and losers are interrupted", async () => { + const trace = makeTrace() + const result = await value( + ` + const fast = tools.host.sleepy({ id: 1, ms: 10 }) + const slow = tools.host.sleepy({ id: 2, ms: 5000 }) + return await Promise.race([fast, slow]) + `, + { trace }, + ) + expect(result).toBe(1) + expect(trace.interrupted).toBe(1) + expect(trace.completed).toBe(1) + }) + + test("awaiting an interrupted loser afterwards is a catchable program failure", async () => { + expect( + await value(` + const fast = tools.host.sleepy({ id: 1, ms: 10 }) + const slow = tools.host.sleepy({ id: 2, ms: 5000 }) + const winner = await Promise.race([fast, slow]) + try { + await slow + return "no" + } catch (e) { + return { winner, caught: e.message } + } + `), + ).toEqual({ + winner: 1, + caught: "This tool call was interrupted because another value settled a Promise.race first.", + }) + }) + + test("a rejection can win the race", async () => { + expect( + await value(` + try { + await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })]) + return "no" + } catch (e) { + return e.message + } + `), + ).toBe("Lookup refused") + }) + + test("a plain value wins over pending promises", async () => { + const trace = makeTrace() + expect( + await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }), + ).toBe("immediate") + expect(trace.interrupted).toBe(1) + }) + + test("an empty race is a clear error instead of hanging", async () => { + const diagnostic = await error(`return await Promise.race([])`) + expect(diagnostic.message).toContain("never settle") + }) +}) + +describe("Promise.resolve / Promise.reject", () => { + test("resolve wraps plain values and passes promises through", async () => { + expect(await value(`return await Promise.resolve(42)`)).toBe(42) + expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested") + expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3) + }) + + test("reject produces a promise whose await throws the reason", async () => { + expect( + await value(` + try { + await Promise.reject("nope") + return "no" + } catch (e) { + return e + } + `), + ).toBe("nope") + }) +}) + +describe("timeout interruption of forked calls", () => { + test("the execution timeout interrupts in-flight forked fibers", async () => { + const trace = makeTrace() + const result = await run( + ` + const a = tools.host.sleepy({ id: 1, ms: 60000 }) + const b = tools.host.sleepy({ id: 2, ms: 60000 }) + return await a + `, + { trace, limits: { timeoutMs: 100 } }, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("TimeoutExceeded") + // Both calls started; neither escaped the timeout - the awaited one AND the abandoned one. + expect(trace.starts).toEqual([1, 2]) + expect(trace.interrupted).toBe(2) + expect(trace.completed).toBe(0) + }) + + test("the timeout also interrupts calls inside Promise.all", async () => { + const trace = makeTrace() + const result = await run( + `return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`, + { trace, limits: { timeoutMs: 100 } }, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("TimeoutExceeded") + expect(trace.interrupted).toBe(2) + }) +}) + +describe("unsupported promise surface", () => { + test(".then/.catch/.finally give a clear await-instead error", async () => { + for (const method of ["then", "catch", "finally"]) { + const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`) + expect(diagnostic.kind).toBe("UnsupportedSyntax") + expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`) + expect(diagnostic.message).toContain("await") + } + }) + + test("other property reads on a promise hint at the missing await", async () => { + const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + expect(diagnostic.message).toContain("await it first") + }) + + test("unknown Promise statics list what is available", async () => { + const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`) + expect(diagnostic.message).toContain("Promise.any is not available") + expect(diagnostic.message).toContain("Promise.allSettled") + }) + + test("new Promise(...) points at tool calls instead", async () => { + const diagnostic = await error(`return new Promise((resolve) => resolve(1))`) + expect(diagnostic.kind).toBe("UnsupportedSyntax") + expect(diagnostic.message).toContain("new Promise(...) is not supported") + expect(diagnostic.message).toContain("already return promises") + }) +}) diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..232c6dcb221d3193c2b6024d3454dcd2eec73120 --- /dev/null +++ b/packages/codemode/test/signature.test.ts @@ -0,0 +1,449 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" +import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js" + +// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema +// whose property descriptions and constraints must surface as JSDoc in pretty signatures. +const listIssues = Tool.make({ + description: "List issues in a repository", + input: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + after: { type: "string", description: "Cursor from the previous response's pageInfo" }, + perPage: { type: "number", description: "Results per page", default: 30 }, + labels: { type: "array", items: { type: "string" }, description: "Filter by labels", minItems: 1, maxItems: 10 }, + state: { type: "string", enum: ["open", "closed"] }, + }, + required: ["owner"], + }, + run: () => Effect.succeed("[]"), +}) + +// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema. +const lookupOrder = Tool.make({ + description: "Look up an order", + input: Schema.Struct({ + id: Schema.String.annotate({ description: "Order identifier" }), + verbose: Schema.optionalKey(Schema.Boolean), + }), + output: Schema.Struct({ + status: Schema.String.annotate({ description: "Current order status" }), + }), + run: () => Effect.succeed({ status: "open" }), +}) + +describe("pretty signature rendering", () => { + test("described fields get JSDoc comments; undescribed and untagged fields get none", () => { + expect(inputTypeScript(listIssues, true)).toBe( + [ + "{", + " /** Repository owner */", + " owner: string,", + " /** Cursor from the previous response's pageInfo */", + " after?: string,", + " /**", + " * Results per page", + " * @default 30", + " */", + " perPage?: number,", + " /**", + " * Filter by labels", + " * @minItems 1", + " * @maxItems 10", + " */", + " labels?: Array,", + ' state?: "open" | "closed",', + "}", + ].join("\n"), + ) + }) + + test("compact mode output is unchanged by the pretty machinery", () => { + expect(inputTypeScript(listIssues)).toBe( + '{ owner: string; after?: string; perPage?: number; labels?: Array; state?: "open" | "closed" }', + ) + expect(inputTypeScript(lookupOrder)).toBe("{ id: string; verbose?: boolean }") + expect(outputTypeScript(lookupOrder)).toBe("{ status: string }") + }) + + test("nested objects recurse with increasing indent and their own JSDoc", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { + filter: { + type: "object", + description: "Search filter", + properties: { state: { type: "string", description: "Issue state" } }, + }, + }, + }, + true, + ) + expect(pretty).toBe( + [ + "{", + " /** Search filter */", + " filter?: {", + " /** Issue state */", + " state?: string,", + " },", + "}", + ].join("\n"), + ) + }) + + test("Effect Schema annotations become JSDoc on input and output fields", () => { + expect(inputTypeScript(lookupOrder, true)).toBe( + ["{", " /** Order identifier */", " id: string,", " verbose?: boolean,", "}"].join("\n"), + ) + expect(outputTypeScript(lookupOrder, true)).toBe( + ["{", " /** Current order status */", " status: string,", "}"].join("\n"), + ) + }) + + test("constraints TypeScript cannot express surface as JSDoc tags", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { + legacy: { type: "string", deprecated: true }, + homepage: { type: "string", format: "uri" }, + tags: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 5, default: ["a", "b"] }, + }, + }, + true, + ) + expect(pretty).toContain(" /** @deprecated */\n legacy?: string") + expect(pretty).toContain(" /** @format uri */\n homepage?: string") + expect(pretty).toContain( + [ + " /**", + ' * @default ["a","b"]', + " * @minItems 2", + " * @maxItems 5", + " */", + " tags?: Array", + ].join("\n"), + ) + }) + + test("skips an unserializable default rather than emitting a broken tag", () => { + const pretty = jsonSchemaToTypeScript( + { type: "object", properties: { size: { type: "number", default: 1n } } }, + true, + ) + expect(pretty).toBe(["{", " size?: number,", "}"].join("\n")) + }) + + test("neutralizes */ inside descriptions so nothing closes the comment early", () => { + const pretty = jsonSchemaToTypeScript( + { type: "object", properties: { note: { type: "string", description: "Ends */ early" } } }, + true, + ) + expect(pretty).toContain(" /** Ends * / early */") + expect(pretty).not.toContain("Ends */") + }) + + test("multiline descriptions become *-prefixed blocks with blank edges trimmed", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { query: { type: "string", description: "\nFirst line\n\nSecond line\n" } }, + }, + true, + ) + expect(pretty).toBe( + ["{", " /**", " * First line", " *", " * Second line", " */", " query?: string,", "}"].join("\n"), + ) + }) + + test("stays total on cyclic $refs and pathological nesting in both modes", () => { + const cyclic = { + $ref: "#/$defs/Node", + $defs: { Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" }, name: { type: "string" } } } }, + } as const + expect(jsonSchemaToTypeScript(cyclic)).toBe("{ child?: unknown; name?: string }") + expect(jsonSchemaToTypeScript(cyclic, true)).toContain("child?: unknown") + + let deep: Record = { type: "string" } + for (let level = 0; level < 12; level += 1) deep = { type: "object", properties: { next: deep } } + for (const pretty of [false, true]) { + const rendered = jsonSchemaToTypeScript(deep, pretty) + expect(rendered).toContain("unknown") + expect(rendered).toContain("next?:") + } + }) + + test("intersects ref and union siblings instead of discarding them", () => { + expect( + jsonSchemaToTypeScript({ + $ref: "#/$defs/User", + properties: { active: { type: "boolean" } }, + required: ["active"], + $defs: { + User: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + }, + }), + ).toBe("{ id: string } & { active: boolean }") + expect( + jsonSchemaToTypeScript({ + type: "object", + properties: { common: { type: "boolean" } }, + required: ["common"], + anyOf: [ + { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + { type: "object", properties: { count: { type: "number" } }, required: ["count"] }, + ], + }), + ).toBe("({ name: string } | { count: number }) & { common: boolean }") + expect(jsonSchemaToTypeScript({ $ref: "https://example.com/schema.json" })).toBe("unknown") + expect( + jsonSchemaToTypeScript({ + $ref: "#/$defs/User/properties/id", + $defs: { User: { type: "object" }, id: { type: "string" } }, + }), + ).toBe("unknown") + expect( + jsonSchemaToTypeScript({ + type: ["object", "null"], + properties: { name: { type: "string" } }, + }), + ).toBe("{ name?: string } | null") + }) +}) + +describe("non-identifier property names render as quoted keys", () => { + // MCP-style schemas routinely carry property names that are not bare TS identifiers + // (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the + // model sees a valid TypeScript object type. Bare identifiers stay unquoted. + const rawSchema = { + type: "object", + properties: { + "foo-bar": { type: "string" }, + "@type": { type: "string" }, + "x.y": { type: "number", description: "Dotted name" }, + "123": { type: "number" }, + plain: { type: "boolean" }, + }, + required: ["@type"], + } as const + + test("compact rendering quotes non-identifier keys and leaves identifiers bare", () => { + expect(jsonSchemaToTypeScript(rawSchema)).toBe( + '{ "123"?: number; "foo-bar"?: string; "@type": string; "x.y"?: number; plain?: boolean }', + ) + }) + + test("pretty rendering quotes non-identifier keys and keeps their JSDoc", () => { + expect(jsonSchemaToTypeScript(rawSchema, true)).toBe( + [ + "{", + ' "123"?: number,', + ' "foo-bar"?: string,', + ' "@type": string,', + " /** Dotted name */", + ' "x.y"?: number,', + " plain?: boolean,", + "}", + ].join("\n"), + ) + }) + + test("JSON Schema input and output signatures of a tool both quote", () => { + const tool = Tool.make({ + description: "Adapter tool with awkward field names", + input: rawSchema, + output: { + type: "object", + properties: { "content-type": { type: "string" } }, + required: ["content-type"], + } as const, + run: () => Effect.succeed({ "content-type": "text/plain" }), + }) + expect(inputTypeScript(tool)).toContain('"foo-bar"?: string') + expect(outputTypeScript(tool)).toBe('{ "content-type": string }') + expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string,', "}"].join("\n")) + }) + + test("Effect Schema structs with non-identifier field names quote too", () => { + const tool = Tool.make({ + description: "Schema tool with awkward field names", + input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }), + run: () => Effect.succeed(null), + }) + expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }') + expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n")) + }) +}) + +describe("union schemas render every alternative", () => { + test("anyOf with a number branch keeps sibling alternatives", () => { + const schema = { + anyOf: [{ type: "string" }, { type: "number" }], + } as const + expect(jsonSchemaToTypeScript(schema)).toBe("string | number") + expect(jsonSchemaToTypeScript(schema, true)).toBe("string | number") + }) + + test("nullable numeric unions keep null", () => { + const schema = { + oneOf: [{ type: "number" }, { type: "null" }], + } as const + expect(jsonSchemaToTypeScript(schema)).toBe("number | null") + expect(jsonSchemaToTypeScript(schema, true)).toBe("number | null") + }) + + test("tool input and output signatures preserve numeric unions", () => { + const tool = Tool.make({ + description: "Tool with numeric unions", + input: { + type: "object", + properties: { + value: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + } as const, + output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const, + run: () => Effect.succeed(1), + }) + expect(inputTypeScript(tool)).toBe("{ value?: string | number }") + expect(outputTypeScript(tool)).toBe("number | boolean") + }) + + test("allOf renders intersections with parenthesized union members", () => { + const schema = { + allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }], + } as const + expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)") + }) + + test("allOf does not discard an unresolved constraint", () => { + expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe( + "unknown", + ) + expect( + jsonSchemaToTypeScript({ + allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }], + }), + ).toBe("unknown") + expect( + jsonSchemaToTypeScript({ + type: "string", + allOf: [{ $ref: "#/$defs/Constraint" }], + $defs: { Constraint: { description: "TypeScript-neutral constraint" } }, + }), + ).toBe("string") + }) +}) + +describe("JSDoc signatures in catalogs and search results", () => { + const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) + + const search = async (query: string) => { + const result = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), + ) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("search failed") + return result.value as { items: Array<{ path: string; signature: string }>; remaining: number } + } + + test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => { + const { items } = await search("list issues repository") + const item = items.find(({ path }) => path === "tools.github.list_issues")! + expect(item.signature).toBe( + [ + "tools.github.list_issues(input: {", + " /** Repository owner */", + " owner: string,", + " /** Cursor from the previous response's pageInfo */", + " after?: string,", + " /**", + " * Results per page", + " * @default 30", + " */", + " perPage?: number,", + " /**", + " * Filter by labels", + " * @minItems 1", + " * @maxItems 10", + " */", + " labels?: Array,", + ' state?: "open" | "closed",', + "}): Promise", + ].join("\n"), + ) + }) + + test("an annotated Effect Schema tool's result signature carries field JSDoc (exact-path lookup too)", async () => { + for (const query of ["look up order", "tools.orders.lookup"]) { + const { items } = await search(query) + const item = items.find(({ path }) => path === "tools.orders.lookup")! + expect(item.signature).toBe( + [ + "tools.orders.lookup(input: {", + " /** Order identifier */", + " id: string,", + " verbose?: boolean,", + "}): Promise<{", + " /** Current order status */", + " status: string,", + "}>", + ].join("\n"), + ) + } + }) + + test("the inline catalog uses the same JSDoc signatures", async () => { + const instructions = runtime.instructions() + const github = (await search("list issues repository")).items.find( + ({ path }) => path === "tools.github.list_issues", + )! + const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")! + expect(instructions).toContain(` - ${github.signature} // List issues in a repository`) + expect(instructions).toContain(` - ${orders.signature} // Look up an order`) + expect(instructions).toContain("/** Repository owner */") + }) +}) + +describe("non-identifier tool paths", () => { + const resolveLibrary = Tool.make({ + description: "Resolve a Context7 library ID", + input: { + type: "object", + properties: { + query: { type: "string" }, + libraryName: { type: "string" }, + }, + required: ["query", "libraryName"], + } as const, + run: () => Effect.succeed("/reactjs/react.dev"), + }) + const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) + + test("inline catalog uses bracket notation for dashed tool names", () => { + const instructions = runtime.instructions() + + expect(instructions).toContain( + 'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise', + ) + expect(instructions).toContain("Do not infer or normalize tool names") + expect(instructions).toContain("bracket notation and quotes are part of the path") + expect(instructions).not.toContain("tools.context7.resolve-library-id") + expect(instructions).not.toContain("tools.context7.resolve_library_id") + }) + + test("search results return callable bracket-notation paths and signatures", async () => { + const result = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`), + ) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("search failed") + + const value = result.value as { items: Array<{ path: string; signature: string }> } + expect(value.items[0]?.path).toBe('tools.context7["resolve-library-id"]') + expect(value.items[0]?.signature).toContain('tools.context7["resolve-library-id"](input: {') + }) +}) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7831a060338939f3a1dc3b7f9186bfe58db145f --- /dev/null +++ b/packages/codemode/test/stdlib.test.ts @@ -0,0 +1,715 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" + +// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS; +// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live +// values, while at the host boundary (final result, tool arguments, JSON.stringify) they +// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null), +// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}. +const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("Date", () => { + test("Date.now() returns a number", async () => { + expect(await value(`return typeof Date.now()`)).toBe("number") + }) + + test("epoch construction and ISO rendering", async () => { + expect(await value(`return new Date(0).toISOString()`)).toBe("1970-01-01T00:00:00.000Z") + }) + + test("string parsing round-trips", async () => { + expect(await value(`return new Date("2024-01-02T03:04:05.000Z").getTime()`)).toBe(1704164645000) + expect(await value(`return Date.parse("2024-01-02T03:04:05.000Z")`)).toBe(1704164645000) + }) + + test("date arithmetic and comparison use the time value", async () => { + expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000) + expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true) + expect(await value(`return +new Date(42)`)).toBe(42) + }) + + test("UTC getters read calendar components", async () => { + expect( + await value( + `const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`, + ), + ).toEqual([2024, 2, 5, 6, 7, 8, 9]) + }) + + test("invalid dates yield NaN times, guardable in-sandbox", async () => { + expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true) + expect(await value(`return new Date("garbage").toJSON()`)).toBeNull() + }) + + test("toISOString on an invalid date is a catchable error", async () => { + expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe( + "caught", + ) + }) + + test("template interpolation renders the ISO form", async () => { + expect(await value("return `at ${new Date(0)}`")).toBe("at 1970-01-01T00:00:00.000Z") + }) + + test("dates serialize to ISO strings at the boundary, direct and nested", async () => { + expect(await value(`return new Date(0)`)).toBe("1970-01-01T00:00:00.000Z") + expect(await value(`return { when: new Date(0), tags: [new Date(1000)] }`)).toEqual({ + when: "1970-01-01T00:00:00.000Z", + tags: ["1970-01-01T00:00:01.000Z"], + }) + expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}') + }) + + test("coercions: Number is the time, String is ISO, Boolean is true", async () => { + expect(await value(`return Number(new Date(5))`)).toBe(5) + expect(await value(`return String(new Date(0))`)).toBe("1970-01-01T00:00:00.000Z") + expect(await value(`return Boolean(new Date(0))`)).toBe(true) + }) + + test("sorting dates with a numeric comparator", async () => { + expect( + await value(` + const dates = [new Date(3000), new Date(1000), new Date(2000)] + return dates.sort((a, b) => a - b).map((d) => d.getTime()) + `), + ).toEqual([1000, 2000, 3000]) + }) + + test("new Date(year, month, day) accepts component form", async () => { + expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([ + 2024, 0, 2, + ]) + }) + + test("typeof and unknown properties are forgiving", async () => { + expect(await value(`return typeof new Date(0)`)).toBe("object") + expect(await value(`return new Date(0).nope === undefined`)).toBe(true) + }) +}) + +describe("RegExp", () => { + test("literal test", async () => { + expect(await value(`return /ab+c/.test("xabbbc")`)).toBe(true) + expect(await value(`return /ab+c/.test("nope")`)).toBe(false) + }) + + test("exec exposes captures and index", async () => { + expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual( + { + full: "abb", + group: "bb", + index: 2, + }, + ) + expect(await value(`return /a/.exec("zzz")`)).toBeNull() + }) + + test("named groups read through", async () => { + expect( + await value(`const m = /(?[a-z]+)-(?\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`), + ).toBe("ab42") + }) + + test("global exec advances lastIndex across calls", async () => { + expect( + await value(` + const r = /\\d+/g + const first = r.exec("a1b22c") + const second = r.exec("a1b22c") + return [first[0], second[0]] + `), + ).toEqual(["1", "22"]) + }) + + test("string match: non-global carries index, global lists all matches", async () => { + expect(await value(`const m = "a1b22".match(/\\d+/); return [m[0], m.index]`)).toEqual(["1", 1]) + expect(await value(`return "a1b22".match(/\\d+/g)`)).toEqual(["1", "22"]) + expect(await value(`return "abc".match(/\\d/)`)).toBeNull() + }) + + test("matchAll materializes match arrays with captures", async () => { + expect(await value(`return "a1b22".matchAll(/(\\d+)/g).map((m) => m[1])`)).toEqual(["1", "22"]) + }) + + test("replace and replaceAll with patterns and $1 substitution", async () => { + expect(await value(`return "a1b2".replace(/\\d/, "#")`)).toBe("a#b2") + expect(await value(`return "a1b2".replace(/\\d/g, "#")`)).toBe("a#b#") + expect(await value(`return "a1b2".replaceAll(/\\d/g, "#")`)).toBe("a#b#") + expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]") + }) + + test("function replacers receive captures, offsets, input, and named groups", async () => { + expect( + await value(` + const seen = [] + const output = "a1b22".replace(/(\\d)(\\d)?/g, (match, first, second, offset, input) => { + seen.push([match, first, second === undefined, offset, input]) + return Number(match) * 2 + }) + return { output, seen } + `), + ).toEqual({ + output: "a2b44", + seen: [ + ["1", "1", true, 1, "a1b22"], + ["22", "2", false, 3, "a1b22"], + ], + }) + expect( + await value(` + return "red-blue".replace( + /(?[a-z]+)-(?[a-z]+)/, + (match, left, right, offset, input, groups) => groups.right + ":" + groups.left, + ) + `), + ).toBe("blue:red") + }) + + test("function replacers support string searches, zero-length matches, and result coercion", async () => { + expect(await value(`return "banana".replace("na", (match, offset, input) => "[" + offset + "]")`)).toBe("ba[2]na") + expect(await value(`return "ab".replaceAll("", (match, offset) => offset)`)).toBe("0a1b2") + expect(await value(`return "😀".replaceAll(/(?:)/gu, (match, offset) => "[" + offset + "]")`)).toBe("[0]😀[2]") + expect( + await value(`return "123".replace(/\\d/g, (match) => match === "1" ? 7 : match === "2" ? null : { n: 3 })`), + ).toBe("7null[object Object]") + }) + + test("function replacers can await effectful tool calls", async () => { + const decorate = Tool.make({ + description: "Decorate a string", + input: Schema.String, + output: Schema.String, + run: (input) => Effect.succeed(`[${input}]`), + }) + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { decorate } }, + code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`, + }), + ) + expect(result.ok && result.value).toBe("a[1]b[22]") + + const missingAwait = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { decorate } }, + code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`, + }), + ) + expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue") + expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise") + }) + + test("replaceAll without the g flag is a catchable error", async () => { + expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught") + }) + + test("split and search accept patterns", async () => { + expect(await value(`return "a1b22c".split(/\\d+/)`)).toEqual(["a", "b", "c"]) + expect(await value(`return "ab42".search(/\\d/)`)).toBe(2) + expect(await value(`return "ab".search(/\\d/)`)).toBe(-1) + }) + + test("new RegExp constructs from strings; invalid patterns are catchable", async () => { + expect(await value(`return new RegExp("a+", "i").test("AAA")`)).toBe(true) + expect(await value(`try { new RegExp("("); return "no" } catch { return "caught" }`)).toBe("caught") + expect(await value(`return [/a/ instanceof RegExp, /a/.source]`)).toEqual([true, "a"]) + }) + + test("invalid patterns fail with actionable messages", async () => { + const fromString = await error(`return "abc".match("(")`) + expect(fromString.message).toContain('String.match received the string "("') + expect(fromString.message).toContain("escape them with a backslash") + + const fromConstructor = await error(`return new RegExp("(")`) + expect(fromConstructor.message).toContain('new RegExp(...) received "("') + expect(fromConstructor.message).toContain("escape them with a backslash") + + const fromFlags = await error(`return new RegExp("a", "xz")`) + expect(fromFlags.message).toContain('invalid flags "xz"') + expect(fromFlags.message).toContain("Valid flags are") + }) + + test("missing g-flag errors say how to fix the call", async () => { + expect((await error(`return "aa".replaceAll(/a/, "b")`)).message).toContain("write /a/g, or use String.replace") + expect((await error(`return "aa".matchAll(/a/)`)).message).toContain("write /a/g, or use String.match") + }) + + test("a non-pattern argument names the expected shapes", async () => { + const err = await error(`return "abc".match(42)`) + expect(err.message).toContain("expects a regular expression") + expect(err.message).toContain("not number") + }) + + test("source and flags properties read through", async () => { + expect(await value(`const r = /ab/gi; return { source: r.source, flags: r.flags, global: r.global }`)).toEqual({ + source: "ab", + flags: "gi", + global: true, + }) + }) + + test("regexes serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return /a/`)).toEqual({}) + expect(await value(`return JSON.stringify({ r: /a/g })`)).toBe('{"r":{}}') + }) + + test("template interpolation renders the literal form", async () => { + expect(await value("return `${/ab/g}`")).toBe("/ab/g") + }) +}) + +describe("URL and URI helpers", () => { + test("encodes and decodes complete URIs and URI components", async () => { + expect( + await value(` + return [ + encodeURI("https://example.test/a b?q=a/b"), + encodeURIComponent("a b/c?"), + decodeURI("https://example.test/a%20b?q=a/b"), + decodeURIComponent("a%20b%2Fc%3F"), + ["a b", "c/d"].map(encodeURIComponent), + ] + `), + ).toEqual([ + "https://example.test/a%20b?q=a/b", + "a%20b%2Fc%3F", + "https://example.test/a b?q=a/b", + "a b/c?", + ["a%20b", "c%2Fd"], + ]) + expect( + await value(`try { decodeURIComponent("%zz"); return false } catch (error) { return error instanceof URIError }`), + ).toBe(true) + }) + + test("resolves and mutates URLs with linked search parameters", async () => { + expect( + await value(` + const url = new URL("../users?id=old#top", "https://user:pass@example.com:8443/api/v1/") + url.pathname = "/items/a b" + url.searchParams.set("id", "a b") + url.searchParams.append("tag", "x/y") + url.hash = "part 1" + return { + href: url.href, + origin: url.origin, + host: url.host, + pathname: url.pathname, + search: url.search, + id: url.searchParams.get("id"), + string: String(url), + json: url.toJSON(), + instances: [ + url instanceof URL, + url.searchParams instanceof URLSearchParams, + url.searchParams === url.searchParams, + ], + } + `), + ).toEqual({ + href: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201", + origin: "https://example.com:8443", + host: "example.com:8443", + pathname: "/items/a%20b", + search: "?id=a+b&tag=x%2Fy", + id: "a b", + string: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201", + json: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201", + instances: [true, true, true], + }) + }) + + test("URLSearchParams supports records, pairs, mutation, callbacks, and materialization", async () => { + expect( + await value(` + const params = new URLSearchParams([["tag", "b"], ["tag", "a"], ["q", "a b"]]) + const seen = [] + params.forEach((value, key) => seen.push(key + "=" + value)) + params.delete("tag", "b") + params.append("tag", "c") + params.sort() + return { + text: params.toString(), + size: params.size, + tags: params.getAll("tag"), + has: params.has("tag", "c"), + entries: Array.from(params), + object: Object.fromEntries(params), + record: new URLSearchParams({ page: 2, filter: "open" }).toString(), + seen, + } + `), + ).toEqual({ + text: "q=a+b&tag=a&tag=c", + size: 3, + tags: ["a", "c"], + has: true, + entries: [ + ["q", "a b"], + ["tag", "a"], + ["tag", "c"], + ], + object: { q: "a b", tag: "c" }, + record: "page=2&filter=open", + seen: ["tag=b", "tag=a", "q=a b"], + }) + }) + + test("URL parsing failures are catchable and values use native JSON forms", async () => { + expect( + await value(` + const parsed = URL.parse("/users", "https://example.test/api/") + let invalidIsTypeError = false + try { new URL("not relative without a base") } catch (error) { invalidIsTypeError = error instanceof TypeError } + return { + canParse: URL.canParse("/users", "https://example.test/api/"), + cannotParse: URL.canParse("not relative without a base"), + parsed: parsed.href, + invalidIsTypeError, + boundary: [new URL("https://example.test/a"), new URLSearchParams("q=one")], + json: JSON.stringify({ url: new URL("https://example.test/a"), params: new URLSearchParams("q=one") }), + } + `), + ).toEqual({ + canParse: true, + cannotParse: false, + parsed: "https://example.test/users", + invalidIsTypeError: true, + boundary: ["https://example.test/a", {}], + json: '{"url":"https://example.test/a","params":{}}', + }) + }) + + test("distinguishes omitted URL arguments from explicit undefined", async () => { + expect( + await value(` + function throwsTypeError(run) { + try { run(); return false } catch (error) { return error instanceof TypeError } + } + const params = new URLSearchParams() + const required = [ + () => params.append(), + () => params.delete(), + () => params.get(), + () => params.getAll(), + () => params.has(), + () => params.set(), + () => params.forEach(), + ].map(throwsTypeError) + params.append(undefined, undefined) + return { + construct: throwsTypeError(() => new URL()), + canParse: throwsTypeError(() => URL.canParse()), + parse: throwsTypeError(() => URL.parse()), + explicitUndefined: new URL(undefined, "https://example.test/base/").href, + params: params.toString(), + required, + } + `), + ).toEqual({ + construct: true, + canParse: true, + parse: true, + explicitUndefined: "https://example.test/base/undefined", + params: "undefined=undefined", + required: [true, true, true, true, true, true, true], + }) + }) +}) + +describe("Map", () => { + test("get/set/has/size with chaining", async () => { + expect( + await value(` + const m = new Map() + m.set("a", 1).set("b", 2) + return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size } + `), + ).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 }) + }) + + test("object keys use identity", async () => { + expect( + await value(` + const key = { id: 1 } + const m = new Map() + m.set(key, "hit") + return [m.get(key), m.get({ id: 1 }) === undefined] + `), + ).toEqual(["hit", true]) + }) + + test("construction from entry pairs and another Map", async () => { + expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2) + expect( + await value( + `const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`, + ), + ).toEqual([1, 2, false]) + expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/) + expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/) + }) + + test("keys/values/entries return arrays", async () => { + expect( + await value(` + const m = new Map([["a", 1], ["b", 2]]) + return { keys: m.keys(), values: m.values(), entries: m.entries() } + `), + ).toEqual({ + keys: ["a", "b"], + values: [1, 2], + entries: [ + ["a", 1], + ["b", 2], + ], + }) + }) + + test("Object.fromEntries(map) and Array.from(map)", async () => { + expect(await value(`return Object.fromEntries(new Map([["a", 1], ["b", 2]]))`)).toEqual({ a: 1, b: 2 }) + expect(await value(`return Array.from(new Map([["a", 1]]))`)).toEqual([["a", 1]]) + }) + + test("for...of iterates [key, value] pairs with destructuring", async () => { + expect( + await value(` + const m = new Map([["a", 1], ["b", 2]]) + let total = 0 + let names = "" + for (const [key, count] of m) { names += key; total += count } + return names + total + `), + ).toBe("ab3") + }) + + test("spread produces entry pairs", async () => { + expect(await value(`return [...new Map([["a", 1]])]`)).toEqual([["a", 1]]) + }) + + test("forEach passes (value, key)", async () => { + expect( + await value(` + const m = new Map([["a", 1], ["b", 2]]) + const seen = [] + m.forEach((count, key) => seen.push(key + count)) + return seen + `), + ).toEqual(["a1", "b2"]) + }) + + test("delete and clear", async () => { + expect( + await value(` + const m = new Map([["a", 1], ["b", 2]]) + const removed = m.delete("a") + const missed = m.delete("zz") + const sizeAfterDelete = m.size + m.clear() + return [removed, missed, sizeAfterDelete, m.size] + `), + ).toEqual([true, false, 1, 0]) + }) + + test("counting idiom: grouped tallies", async () => { + expect( + await value(` + const words = ["a", "b", "a", "c", "a"] + const counts = new Map() + for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1) + return Object.fromEntries(counts) + `), + ).toEqual({ a: 3, b: 1, c: 1 }) + }) + + test("maps serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return new Map([["a", 1]])`)).toEqual({}) + expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}") + }) + + test("console.log renders map contents for debugging", async () => { + const result = await run(`console.log(new Map([["a", 1]])); return null`) + expect(result.ok).toBe(true) + expect(result.logs?.[0]).toBe(`Map(1) [["a",1]]`) + }) +}) + +describe("Set", () => { + test("add/has/delete/size with chaining", async () => { + expect( + await value(` + const s = new Set() + s.add(1).add(2).add(1) + const removed = s.delete(2) + return [s.size, s.has(1), s.has(2), removed] + `), + ).toEqual([1, true, false, true]) + }) + + test("dedupe idiom: [...new Set(items)]", async () => { + expect(await value(`return [...new Set([1, 2, 2, 3, 1])]`)).toEqual([1, 2, 3]) + }) + + test("construction from strings and other Sets", async () => { + expect(await value(`return [...new Set("aba")]`)).toEqual(["a", "b"]) + expect(await value(`return Array.from(new Set(new Set([1, 2])))`)).toEqual([1, 2]) + }) + + test("SameValueZero: NaN is findable", async () => { + expect(await value(`const s = new Set([NaN]); return s.has(NaN)`)).toBe(true) + }) + + test("for...of iterates values", async () => { + expect( + await value(` + let total = 0 + for (const n of new Set([1, 2, 3])) total += n + return total + `), + ).toBe(6) + }) + + test("sets serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return { s: new Set([1]) }`)).toEqual({ s: {} }) + }) +}) + +describe("stdlib integration", () => { + test("typeof reports constructors as functions and never throws", async () => { + expect(await value(`return typeof Map`)).toBe("function") + expect(await value(`return typeof ((x) => x)`)).toBe("function") + expect(await value(`return typeof Math`)).toBe("object") + expect(await value(`return typeof tools`)).toBe("object") + }) + + test("negation works on any value", async () => { + expect(await value(`return !new Map()`)).toBe(false) + expect(await value(`const fn = () => 1; return !fn`)).toBe(false) + }) + + test("object spread of sandbox values is a no-op, like JS", async () => { + expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true }) + }) + + test("dates inside Map values survive in-sandbox reads", async () => { + expect( + await value(` + const m = new Map([["start", new Date(1000)]]) + return m.get("start").getTime() + `), + ).toBe(1000) + }) + + test("instanceof recognizes the stdlib value types", async () => { + expect( + await value( + `return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`, + ), + ).toEqual([true, true, true, true]) + expect( + await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`), + ).toEqual([true, true, true, false]) + expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false]) + expect( + await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`), + ).toBe(true) + }) + + test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => { + expect( + await value(` + const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]' + const rows = JSON.parse(raw) + const tags = new Set() + const byDay = new Map() + for (const row of rows) { + for (const m of row.tag.matchAll(/[a-z]+/g)) tags.add(m[0]) + const day = new Date(row.at).toISOString().slice(0, 10) + byDay.set(day, (byDay.get(day) ?? 0) + 1) + } + return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) } + `), + ).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } }) + }) +}) + +describe("sandbox values at intra-sandbox checkpoints", () => { + test("Object.values/entries keep Dates usable", async () => { + expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0) + expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe( + "d:0", + ) + }) + + test("Object.assign keeps Maps usable", async () => { + expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe( + 1, + ) + }) + + test("object and array spread keep sandbox values usable", async () => { + expect( + await value(` + const src = { m: new Map([["a", 1]]) } + const copy = { ...src } + copy.m.set("b", 2) + return [copy.m.get("a"), src.m.get("b")] + `), + ).toEqual([1, 2]) + expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000) + }) + + test("Array.from over arrays keeps nested sandbox values usable", async () => { + expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5) + }) + + test("regexes stay callable through Object.values", async () => { + expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true) + }) + + test("Object.* helpers see sandbox values as empty objects, never internals", async () => { + expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([]) + expect(await value(`return Object.values(new Date(0))`)).toEqual([]) + expect(await value(`return Object.entries(new Set([1]))`)).toEqual([]) + expect(await value(`return Object.assign({}, new Map([["a", 1]]))`)).toEqual({}) + expect(await value(`return Object.hasOwn(new Date(0), "time")`)).toBe(false) + }) + + test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => { + expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ + d: "1970-01-01T00:00:00.000Z", + m: {}, + }) + expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}') + + const observed: Array = [] + const capture = Tool.make({ + description: "Capture the exact input the host receives", + input: { type: "object" }, + run: (input) => + Effect.sync(() => { + observed.push(input) + return "ok" + }), + }) + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { capture } }, + code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`, + }), + ) + expect(result.ok).toBe(true) + expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }]) + }) +}) diff --git a/packages/containers/base/Dockerfile b/packages/containers/base/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a81f4baa22a484201552e17c965a80b66c00a14c --- /dev/null +++ b/packages/containers/base/Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + git \ + jq \ + openssh-client \ + pkg-config \ + python3 \ + unzip \ + xz-utils \ + zip \ + && rm -rf /var/lib/apt/lists/* diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..635ac62f6b439c16e886e799f21220c1161f5d22 --- /dev/null +++ b/packages/containers/bun-node/Dockerfile @@ -0,0 +1,24 @@ +ARG REGISTRY=ghcr.io/anomalyco +FROM ${REGISTRY}/build/base:24.04 + +SHELL ["/bin/bash", "-lc"] + +ARG NODE_VERSION=24.4.0 +ARG BUN_VERSION=1.3.14 + +ENV BUN_INSTALL=/opt/bun +ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN set -euo pipefail; \ + arch=$(uname -m); \ + node_arch=x64; \ + if [ "$arch" = "aarch64" ]; then node_arch=arm64; fi; \ + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" \ + | tar -xJf - -C /usr/local --strip-components=1; \ + corepack enable + +RUN set -euo pipefail; \ + curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}"; \ + bun --version; \ + node --version; \ + npm --version diff --git a/packages/containers/publish/Dockerfile b/packages/containers/publish/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4780d22740f6fbd030aba01bc10f551679fedeb8 --- /dev/null +++ b/packages/containers/publish/Dockerfile @@ -0,0 +1,10 @@ +ARG REGISTRY=ghcr.io/anomalyco +FROM ${REGISTRY}/build/bun-node:24.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + docker.io \ + pacman-package-manager \ + && rm -rf /var/lib/apt/lists/* diff --git a/packages/containers/rust/Dockerfile b/packages/containers/rust/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..533f348be76f6f354cad5f3c6f1552e34316525c --- /dev/null +++ b/packages/containers/rust/Dockerfile @@ -0,0 +1,13 @@ +ARG REGISTRY=ghcr.io/anomalyco +FROM ${REGISTRY}/build/bun-node:24.04 + +ARG RUST_TOOLCHAIN=stable + +ENV CARGO_HOME=/opt/cargo +ENV RUSTUP_HOME=/opt/rustup +ENV PATH=/opt/cargo/bin:/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN set -euo pipefail; \ + curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}"; \ + rustc --version; \ + cargo --version diff --git a/packages/containers/script/build.ts b/packages/containers/script/build.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b880e7a5b9cd6ee3ebb742b541a7d911906bb91 --- /dev/null +++ b/packages/containers/script/build.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import path from "path" +import { fileURLToPath } from "url" + +const rootDir = fileURLToPath(new URL("../../..", import.meta.url)) +process.chdir(rootDir) + +const reg = process.env.REGISTRY ?? "ghcr.io/anomalyco" +const tag = process.env.TAG ?? "24.04" +const push = process.argv.includes("--push") || process.env.PUSH === "1" + +const root = path.join(rootDir, "package.json") +const pkg = await Bun.file(root).json() +const manager = pkg.packageManager ?? "" +const bun = manager.startsWith("bun@") ? manager.slice(4) : "" +if (!bun) throw new Error("packageManager must be bun@") + +const images = ["base", "bun-node", "rust", "tauri-linux", "publish"] + +const setup = async () => { + if (!push) return + const list = await $`docker buildx ls`.text() + if (list.includes("opencode")) { + await $`docker buildx use opencode` + return + } + await $`docker buildx create --name opencode --use` +} + +await setup() + +const platform = "linux/amd64,linux/arm64" + +for (const name of images) { + const image = `${reg}/build/${name}:${tag}` + const file = `packages/containers/${name}/Dockerfile` + if (name === "base") { + if (push) { + console.log(`docker buildx build --platform ${platform} -f ${file} -t ${image} --push .`) + await $`docker buildx build --platform ${platform} -f ${file} -t ${image} --push .` + } + if (!push) { + console.log(`docker build -f ${file} -t ${image} .`) + await $`docker build -f ${file} -t ${image} .` + } + } + if (name === "bun-node") { + if (push) { + console.log( + `docker buildx build --platform ${platform} -f ${file} -t ${image} --build-arg REGISTRY=${reg} --build-arg BUN_VERSION=${bun} --push .`, + ) + await $`docker buildx build --platform ${platform} -f ${file} -t ${image} --build-arg REGISTRY=${reg} --build-arg BUN_VERSION=${bun} --push .` + } + if (!push) { + console.log(`docker build -f ${file} -t ${image} --build-arg REGISTRY=${reg} --build-arg BUN_VERSION=${bun} .`) + await $`docker build -f ${file} -t ${image} --build-arg REGISTRY=${reg} --build-arg BUN_VERSION=${bun} .` + } + } + if (name !== "base" && name !== "bun-node") { + if (push) { + console.log( + `docker buildx build --platform ${platform} -f ${file} -t ${image} --build-arg REGISTRY=${reg} --push .`, + ) + await $`docker buildx build --platform ${platform} -f ${file} -t ${image} --build-arg REGISTRY=${reg} --push .` + } + if (!push) { + console.log(`docker build -f ${file} -t ${image} --build-arg REGISTRY=${reg} .`) + await $`docker build -f ${file} -t ${image} --build-arg REGISTRY=${reg} .` + } + } + + if (push) { + console.log(`pushed ${image}`) + } +} diff --git a/packages/containers/tauri-linux/Dockerfile b/packages/containers/tauri-linux/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..9f67a28049875a66834f629c1a4b0b820063b5ee --- /dev/null +++ b/packages/containers/tauri-linux/Dockerfile @@ -0,0 +1,12 @@ +ARG REGISTRY=ghcr.io/anomalyco +FROM ${REGISTRY}/build/rust:24.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libappindicator3-dev \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + patchelf \ + && rm -rf /var/lib/apt/lists/* diff --git a/packages/desktop/icons/README.md b/packages/desktop/icons/README.md new file mode 100644 index 0000000000000000000000000000000000000000..cf2f8e24c50e7d2cc28ad367b5240f841790adc8 --- /dev/null +++ b/packages/desktop/icons/README.md @@ -0,0 +1,14 @@ +# Tauri Icons + +Here's the process I've been using to create icons: + +- Save source image as `app-icon.png` in `packages/desktop` +- `cd` to `packages/desktop` +- Run `bun tauri icon -o src-tauri/icons/{environment}` +- Use [Image2Icon](https://img2icnsapp.com/)'s 'Big Sur Icon' preset to generate an `icon.icns` file and place it in the appropriate icons folder + +The Image2Icon step is necessary as the `icon.icns` generated by `app-icon.png` does not apply the shadow/padding expected by macOS, +so app icons appear larger than expected. + +For unpackaged Electron on macOS, `app.dock.setIcon()` should use a PNG. Keep `dock.png` in each channel folder synced with the +extracted `icon_128x128@2x.png` from that channel's `icon.icns` so the dev Dock icon matches the packaged app inset. diff --git a/packages/desktop/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000000000000000000000000000000000..2ffbf24b68988bd935f5c695a59753fcb1a5de0e --- /dev/null +++ b/packages/desktop/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..39d1dd0d51972e758ef4e1b42c945b5d83473ec6 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..84908e71c1fc31e7d10ab77e9cef24f9b00470ea Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..a6b8cb61624f0962c2eb03408b459c0907f00303 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..6522e0fba8adc1c43620831681e9735b526ed42c Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..b3449bd4f3f40f9a623e230d82c58bc623c8fcd6 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..7aa97d82761983f11304191dcacac814e9ac7cae Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..82bc9d22a694fb92ac4eadd37d590cf2b862d47f Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..6b031ce8515a09612b6fcd09a61486b4ca5bcef8 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..34859de5ef067416d7d8062d3e26085d634a74f7 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4cdb71d62b64cef6518390662b3e4e89bc2f3296 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..a64be6ada1d8d7166d254589467467e56c57e669 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..2de3c27342a7fae36d2b472c9e0747f588e06065 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0ead288664dbcbf86bfc8844e7c1aafc10f7d442 Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..69f74758ecfea71d8e773d35e6efeabbbe54fc8c Binary files /dev/null and b/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/beta/android/values/ic_launcher_background.xml b/packages/desktop/icons/beta/android/values/ic_launcher_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..ea9c223a6cbab0465000584a9d5dd052adffe3c2 --- /dev/null +++ b/packages/desktop/icons/beta/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/packages/desktop/icons/beta/ios/AppIcon-20x20@1x.png b/packages/desktop/icons/beta/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..e8ebb28efe1e774ffc9374a333b3a4218e8b501f Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-20x20@1x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-20x20@2x-1.png b/packages/desktop/icons/beta/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000000000000000000000000000000000000..50c8015dea46b8e177395b5f894c2545e93ed950 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-20x20@2x-1.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-20x20@2x.png b/packages/desktop/icons/beta/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..50c8015dea46b8e177395b5f894c2545e93ed950 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-20x20@2x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-20x20@3x.png b/packages/desktop/icons/beta/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6e290dbc6899b5d7992fd79100249f776d6a7501 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-20x20@3x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-29x29@1x.png b/packages/desktop/icons/beta/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4ef554b4de3a95777dd95614d35f887418ccf7c1 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-29x29@1x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-29x29@2x-1.png b/packages/desktop/icons/beta/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000000000000000000000000000000000000..b9ddfd47c884d2f936f54cf568a850bfcd0c3be1 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-29x29@2x-1.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-29x29@2x.png b/packages/desktop/icons/beta/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b9ddfd47c884d2f936f54cf568a850bfcd0c3be1 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-29x29@2x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-29x29@3x.png b/packages/desktop/icons/beta/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..052322d682168deb0a54d1ee790c1ef8d5015555 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-29x29@3x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-40x40@1x.png b/packages/desktop/icons/beta/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..50c8015dea46b8e177395b5f894c2545e93ed950 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-40x40@1x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-40x40@2x-1.png b/packages/desktop/icons/beta/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000000000000000000000000000000000000..9317b25001cf8397736e5f4cbe99a48c73c2abe3 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-40x40@2x-1.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-40x40@2x.png b/packages/desktop/icons/beta/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9317b25001cf8397736e5f4cbe99a48c73c2abe3 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-40x40@2x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-40x40@3x.png b/packages/desktop/icons/beta/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6b921a17e342d1f85b737f1be690567bcb767c45 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-40x40@3x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-60x60@2x.png b/packages/desktop/icons/beta/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6b921a17e342d1f85b737f1be690567bcb767c45 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-60x60@2x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-60x60@3x.png b/packages/desktop/icons/beta/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..685004995cc7a74942cabee40c0ac1c6d1ef321f Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-60x60@3x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-76x76@1x.png b/packages/desktop/icons/beta/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..1ffceb752a5b9e53ca8ba70a47b5eb5e570117c1 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-76x76@1x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-76x76@2x.png b/packages/desktop/icons/beta/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..81c4178c91204707cd704ca3d684a960983d28c7 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-76x76@2x.png differ diff --git a/packages/desktop/icons/beta/ios/AppIcon-83.5x83.5@2x.png b/packages/desktop/icons/beta/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d5453adffbd9d62639d0e326c6dbdf49638d9be5 Binary files /dev/null and b/packages/desktop/icons/beta/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000000000000000000000000000000000..2ffbf24b68988bd935f5c695a59753fcb1a5de0e --- /dev/null +++ b/packages/desktop/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b355e37fea6e3483a8939b9542f5fe3948b4cc09 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..c33f8713bc11089e1a2428edea597c7f5690d2d0 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..04e37aa65443bf97a8c0ace6215b8896ec251359 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..98e53cd220a6e2d103325519518323d71d4ad77a Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..40fe6e37863e2aa0d63df7c233bca6acb06492ea Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..4814f1ddf5bfbb59cbc373261f72abd96e62c170 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..608493283e4e1feec390a032a9bee515b86f7fef Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..898066a3fc02dad7fed9f0a17a3f991943cde2b3 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..64035c0f3c486921f8870a7ed6ddcd9dea5c40f1 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..f47691bf4281e8bf02cbbd38c4dcc7bea28725e2 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..dba6f5635b76d3fc04384c9b048b6c5f65823703 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..764702604e38547853d7a2f88aa5f82302024a94 Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..2e8430a604c267b9d06ed123aa38552cbecc270a Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..d5c9ba6a8d135deaea3b733f32d91f5946c0b9dd Binary files /dev/null and b/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/dev/android/values/ic_launcher_background.xml b/packages/desktop/icons/dev/android/values/ic_launcher_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..ea9c223a6cbab0465000584a9d5dd052adffe3c2 --- /dev/null +++ b/packages/desktop/icons/dev/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/packages/desktop/icons/dev/ios/AppIcon-20x20@1x.png b/packages/desktop/icons/dev/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..0e823043e76dde0c2fa69b36229c64b3211af479 Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-20x20@1x.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-20x20@2x-1.png b/packages/desktop/icons/dev/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000000000000000000000000000000000000..54e4b2aacab7bb3958462ad952928e5e4f6c6c94 Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-20x20@2x-1.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-29x29@1x.png b/packages/desktop/icons/dev/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..054225c6e9fe95bf6ce2929d539d138d19251f3e Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-29x29@1x.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-29x29@2x-1.png b/packages/desktop/icons/dev/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000000000000000000000000000000000000..0b1b2e0b7fe55551f1ed24304e3e55b1accff442 Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-29x29@2x-1.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-29x29@2x.png b/packages/desktop/icons/dev/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0b1b2e0b7fe55551f1ed24304e3e55b1accff442 Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-29x29@2x.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-40x40@1x.png b/packages/desktop/icons/dev/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..54e4b2aacab7bb3958462ad952928e5e4f6c6c94 Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-40x40@1x.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-40x40@3x.png b/packages/desktop/icons/dev/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..1a490cbf16ff8acd5151f6a79e3851ef082690d9 Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-40x40@3x.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-60x60@2x.png b/packages/desktop/icons/dev/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1a490cbf16ff8acd5151f6a79e3851ef082690d9 Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-60x60@2x.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-76x76@1x.png b/packages/desktop/icons/dev/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..d22096a2dfeb5650a4d3d61d111c7bf769db94ac Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-76x76@1x.png differ diff --git a/packages/desktop/icons/dev/ios/AppIcon-76x76@2x.png b/packages/desktop/icons/dev/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d675773d17e8d9e6145d9e40a9bebeb49fb7c0f8 Binary files /dev/null and b/packages/desktop/icons/dev/ios/AppIcon-76x76@2x.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000000000000000000000000000000000..2ffbf24b68988bd935f5c695a59753fcb1a5de0e --- /dev/null +++ b/packages/desktop/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4f3ea0e3672fc39b1639419b2efe3987ea21cc1f Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..7db80699bccb6de7ad32f83aee96c7bc99099c01 Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..a54ebe652867f41352b8d205e29549cf04b9c2bc Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..9337ccfa3fcd6d8413665263bb40c1a615b639c4 Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..0bfc1082e68e22b3b84b9a3f2129cbe8e26c243a Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..5b02ec732e3d3643a7e97e037d2da4820eebfe45 Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..322aeaeaaad80c681f9552b7ca04d71a3d9eef93 Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..ca1e336cc34f832c14dbc9c96d288ad835f59890 Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..f71110799203cb6d1f03b473d79ef295dc6ca71d Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..287a6b500b68264161848725e188a71f2bdeb28c Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..9d3d06a867b91c13399c57e933edac6e3c27cfba Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..d4b6fde1b81357a4f12c9588fc4a483c255c3ef8 Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..bde8d75967a847bd16b79e449b71260c7fabd8ac Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..62363be04709806544a78b772903b3e38b3e6516 Binary files /dev/null and b/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/packages/desktop/icons/prod/android/values/ic_launcher_background.xml b/packages/desktop/icons/prod/android/values/ic_launcher_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..ea9c223a6cbab0465000584a9d5dd052adffe3c2 --- /dev/null +++ b/packages/desktop/icons/prod/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/packages/desktop/icons/prod/ios/AppIcon-20x20@1x.png b/packages/desktop/icons/prod/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..eb137e164af5a9fb82211afa6bc55d5007aef1f8 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-20x20@1x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-20x20@2x-1.png b/packages/desktop/icons/prod/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000000000000000000000000000000000000..aa76ab10bae39381571baa193742930cbc0aee8d Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-20x20@2x-1.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-20x20@2x.png b/packages/desktop/icons/prod/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..aa76ab10bae39381571baa193742930cbc0aee8d Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-20x20@2x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-20x20@3x.png b/packages/desktop/icons/prod/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..c58ea3d49bda77c0027f24b9f1f87b09bf68f1ef Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-20x20@3x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-29x29@1x.png b/packages/desktop/icons/prod/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..0eeb4d9bf9eed5a567b999de96e95306e1571245 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-29x29@1x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-29x29@2x-1.png b/packages/desktop/icons/prod/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000000000000000000000000000000000000..32601c70a146908aa858ebbd2c2b9453365afc81 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-29x29@2x-1.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-29x29@2x.png b/packages/desktop/icons/prod/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..32601c70a146908aa858ebbd2c2b9453365afc81 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-29x29@2x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-29x29@3x.png b/packages/desktop/icons/prod/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a372c4a111f49b88b1e36f4c5dd336b9107c9b10 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-29x29@3x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-40x40@1x.png b/packages/desktop/icons/prod/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..aa76ab10bae39381571baa193742930cbc0aee8d Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-40x40@1x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-40x40@2x-1.png b/packages/desktop/icons/prod/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000000000000000000000000000000000000..e82ce2765f139a75abefaa155755e12fefdf1bd0 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-40x40@2x-1.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-40x40@2x.png b/packages/desktop/icons/prod/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e82ce2765f139a75abefaa155755e12fefdf1bd0 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-40x40@2x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-40x40@3x.png b/packages/desktop/icons/prod/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..15ad5936285fa306ce4532cfe4b0f01eb76bc1c2 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-40x40@3x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-60x60@2x.png b/packages/desktop/icons/prod/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..15ad5936285fa306ce4532cfe4b0f01eb76bc1c2 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-60x60@2x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-60x60@3x.png b/packages/desktop/icons/prod/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..5c66bd3b18133870c4aa64a7faf4ffec6bb2833d Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-60x60@3x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-76x76@1x.png b/packages/desktop/icons/prod/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..a5b05f3b50fd7be4886ff6d454fef8e4d7f23fc0 Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-76x76@1x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-76x76@2x.png b/packages/desktop/icons/prod/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9c0615d411dff5162d73d6a67a3f11a11a56d9bd Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-76x76@2x.png differ diff --git a/packages/desktop/icons/prod/ios/AppIcon-83.5x83.5@2x.png b/packages/desktop/icons/prod/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6b792b36ad370bd67812e301b34b80b6695e7f1f Binary files /dev/null and b/packages/desktop/icons/prod/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/packages/desktop/resources/entitlements.plist b/packages/desktop/resources/entitlements.plist new file mode 100644 index 0000000000000000000000000000000000000000..b61dc0222802fc47dc7d973b8872619839669081 --- /dev/null +++ b/packages/desktop/resources/entitlements.plist @@ -0,0 +1,18 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-executable-page-protection + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.audio-input + + + diff --git a/packages/desktop/resources/linux/opencode-desktop.desktop b/packages/desktop/resources/linux/opencode-desktop.desktop new file mode 100644 index 0000000000000000000000000000000000000000..a5f677412c097d9d0f04a6af766cfb44b95dd183 --- /dev/null +++ b/packages/desktop/resources/linux/opencode-desktop.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=OpenCode +Exec=/opt/OpenCode/ai.opencode.desktop %U +Terminal=false +Type=Application +Icon=ai.opencode.desktop +StartupWMClass=ai.opencode.desktop +NoDisplay=true +Comment=Open source AI coding agent +Categories=Development; diff --git a/packages/desktop/scripts/copy-bundles.ts b/packages/desktop/scripts/copy-bundles.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ef3335eb79ac9cc41146982b1984d7f552ee20f --- /dev/null +++ b/packages/desktop/scripts/copy-bundles.ts @@ -0,0 +1,12 @@ +import { $ } from "bun" +import * as path from "node:path" + +import { RUST_TARGET } from "./utils" + +if (!RUST_TARGET) throw new Error("RUST_TARGET not defined") + +const BUNDLE_DIR = "dist" +const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles") + +await $`mkdir -p ${BUNDLES_OUT_DIR}` +await $`cp -r ${BUNDLE_DIR}/* ${BUNDLES_OUT_DIR}` diff --git a/packages/desktop/scripts/copy-icons.ts b/packages/desktop/scripts/copy-icons.ts new file mode 100644 index 0000000000000000000000000000000000000000..400f42757101a0c1ab5a00570f1d7a61cb8a58c3 --- /dev/null +++ b/packages/desktop/scripts/copy-icons.ts @@ -0,0 +1,12 @@ +import { $ } from "bun" +import { resolveChannel } from "./utils" + +const arg = process.argv[2] +const channel = arg === "dev" || arg === "beta" || arg === "prod" ? arg : resolveChannel() + +const src = `./icons/${channel}` +const dest = "resources/icons" + +await $`rm -rf ${dest}` +await $`cp -R ${src} ${dest}` +console.log(`Copied ${channel} icons from ${src} to ${dest}`) diff --git a/packages/desktop/scripts/copy-metainfo.ts b/packages/desktop/scripts/copy-metainfo.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7585ccafdae2faf1676c6c52f0917340b031ddd --- /dev/null +++ b/packages/desktop/scripts/copy-metainfo.ts @@ -0,0 +1,47 @@ +import { resolveChannel } from "./utils" + +const arg = process.argv[2] +const channel = arg === "dev" || arg === "beta" || arg === "prod" ? arg : resolveChannel() + +const appId = channel === "prod" ? "ai.opencode.desktop" : `ai.opencode.desktop.${channel}` +const productName = channel === "prod" ? "OpenCode" : `OpenCode ${channel.charAt(0).toUpperCase() + channel.slice(1)}` +const summary = `Open source AI coding agent${channel !== "prod" ? ` (${channel})` : ""}` + +const xml = ` + + ${appId} + + CC0-1.0 + MIT + + ${productName} + ${summary} + + + Anomaly Innovations Inc. + + + +

+ OpenCode is an open source agent that helps you write and run code with any AI model. +

+
+ + ${appId}.desktop + + + + https://github.com/anomalyco/opencode/issues + https://opencode.ai + https://github.com/anomalyco/opencode + + + + https://raw.githubusercontent.com/anomalyco/opencode/b75d4d1c5ec449585d515c756fc81f080a157a9a/packages/web/src/assets/lander/screenshot.png + + +
+` + +await Bun.write(`resources/${appId}.metainfo.xml`, xml) +console.log(`Generated metainfo for ${channel} at resources/${appId}.metainfo.xml`) diff --git a/packages/desktop/scripts/finalize-latest-json.ts b/packages/desktop/scripts/finalize-latest-json.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb0f26b94dfc050431a7093dee20a96491e70b8e --- /dev/null +++ b/packages/desktop/scripts/finalize-latest-json.ts @@ -0,0 +1,219 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import path from "node:path" +import { parseArgs } from "node:util" + +const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + "dry-run": { type: "boolean", default: false }, + }, +}) + +const dryRun = values["dry-run"] + +const repo = process.env.GH_REPO +if (!repo) throw new Error("GH_REPO is required") + +const releaseId = process.env.OPENCODE_RELEASE +if (!releaseId) throw new Error("OPENCODE_RELEASE is required") + +const version = process.env.OPENCODE_VERSION +if (!version) throw new Error("OPENCODE_VERSION is required") + +const dir = process.env.LATEST_YML_DIR +if (!dir) throw new Error("LATEST_YML_DIR is required") +const root = dir + +const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN +if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") + +const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github+json", + }, +}) + +if (!rel.ok) { + throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`) +} + +type Asset = { + name: string + url: string +} + +type Release = { + assets?: Asset[] +} + +const assets = ((await rel.json()) as Release).assets ?? [] +const amap = new Map(assets.map((item) => [item.name, item])) + +type Item = { + url: string +} + +type Yml = { + version: string + files: Item[] +} + +function parse(text: string): Yml { + const lines = text.split("\n") + let version = "" + const files: Item[] = [] + let url = "" + + const flush = () => { + if (!url) return + files.push({ url }) + url = "" + } + + for (const line of lines) { + const trim = line.trim() + if (line.startsWith("version:")) { + version = line.slice("version:".length).trim() + continue + } + if (trim.startsWith("- url:")) { + flush() + url = trim.slice("- url:".length).trim() + continue + } + const indented = line.startsWith(" ") || line.startsWith("\t") + if (!indented) flush() + } + flush() + + return { version, files } +} + +async function read(sub: string, file: string) { + const item = Bun.file(path.join(root, sub, file)) + if (!(await item.exists())) return undefined + return parse(await item.text()) +} + +function pick(list: Item[], exts: string[]) { + for (const ext of exts) { + const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext)) + if (found) return found.url + } +} + +function link(raw: string) { + if (raw.startsWith("https://") || raw.startsWith("http://")) return raw + return `https://github.com/${repo}/releases/download/v${version}/${raw}` +} + +async function sign(url: string, key: string) { + const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key) + const asset = amap.get(name) + const res = await fetch(asset?.url ?? url, { + headers: { + Authorization: `token ${token}`, + ...(asset ? { Accept: "application/octet-stream" } : {}), + }, + }) + if (!res.ok) { + throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`) + } + + const tmp = process.env.RUNNER_TEMP ?? "/tmp" + const file = path.join(tmp, name) + await Bun.write(file, await res.arrayBuffer()) + await $`bunx @tauri-apps/cli signer sign ${file}` + const sigFile = Bun.file(`${file}.sig`) + if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`) + return (await sigFile.text()).trim() +} + +const add = async (data: Record, key: string, raw: string | undefined) => { + if (!raw) return + if (data[key]) return + const url = link(raw) + data[key] = { url, signature: await sign(url, key) } +} + +const alias = (data: Record, key: string, src: string) => { + if (data[key]) return + if (!data[src]) return + data[key] = data[src] +} + +const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml") +const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml") +const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml") +const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml") +const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml") +const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml") + +const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version +if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`) + +const out: Record = {} + +const winxexe = pick(winx?.files ?? [], [".exe"]) +const winaexe = pick(wina?.files ?? [], [".exe"]) + +const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz" +const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz" + +const linxDeb = pick(linx?.files ?? [], [".deb"]) +const linxRpm = pick(linx?.files ?? [], [".rpm"]) +const linxAppImage = pick(linx?.files ?? [], [".appimage"]) +const linaDeb = pick(lina?.files ?? [], [".deb"]) +const linaRpm = pick(lina?.files ?? [], [".rpm"]) +const linaAppImage = pick(lina?.files ?? [], [".appimage"]) + +await add(out, "windows-x86_64-nsis", winxexe) +await add(out, "windows-aarch64-nsis", winaexe) +await add(out, "darwin-x86_64-app", macxTarGz) +await add(out, "darwin-aarch64-app", macaTarGz) + +await add(out, "linux-x86_64-deb", linxDeb) +await add(out, "linux-x86_64-rpm", linxRpm) +await add(out, "linux-x86_64-appimage", linxAppImage) +await add(out, "linux-aarch64-deb", linaDeb) +await add(out, "linux-aarch64-rpm", linaRpm) +await add(out, "linux-aarch64-appimage", linaAppImage) + +alias(out, "windows-x86_64", "windows-x86_64-nsis") +alias(out, "windows-aarch64", "windows-aarch64-nsis") +alias(out, "darwin-x86_64", "darwin-x86_64-app") +alias(out, "darwin-aarch64", "darwin-aarch64-app") +alias(out, "linux-x86_64", "linux-x86_64-deb") +alias(out, "linux-aarch64", "linux-aarch64-deb") + +const platforms = Object.fromEntries( + Object.keys(out) + .sort() + .map((key) => [key, out[key]]), +) + +if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts") + +const data = { + version, + notes: "", + pub_date: new Date().toISOString(), + platforms, +} + +const tmp = process.env.RUNNER_TEMP ?? "/tmp" +const file = path.join(tmp, "latest.json") +await Bun.write(file, JSON.stringify(data, null, 2)) + +const tag = `v${version}` + +if (dryRun) { + console.log(`dry-run: wrote latest.json for ${tag} to ${file}`) + process.exit(0) +} +await $`gh release upload ${tag} ${file} --clobber --repo ${repo}` + +console.log(`finalized latest.json for ${tag}`) diff --git a/packages/desktop/scripts/finalize-latest-yml.ts b/packages/desktop/scripts/finalize-latest-yml.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa2ae5c96e6572bbfd7e1991f7effc7ad1e9fd46 --- /dev/null +++ b/packages/desktop/scripts/finalize-latest-yml.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import path from "path" + +const dir = process.env.LATEST_YML_DIR! +if (!dir) throw new Error("LATEST_YML_DIR is required") + +const repo = process.env.GH_REPO +if (!repo) throw new Error("GH_REPO is required") + +const version = process.env.OPENCODE_VERSION +if (!version) throw new Error("OPENCODE_VERSION is required") + +type FileEntry = { + url: string + sha512: string + size: number + blockMapSize?: number +} + +type LatestYml = { + version: string + files: FileEntry[] + releaseDate: string +} + +function parse(content: string): LatestYml { + const lines = content.split("\n") + let version = "" + let releaseDate = "" + const files: FileEntry[] = [] + let current: Partial | undefined + + const flush = () => { + if (current?.url && current.sha512 && current.size) files.push(current as FileEntry) + current = undefined + } + + for (const line of lines) { + const indented = line.startsWith(" ") || line.startsWith(" -") + if (line.startsWith("version:")) version = line.slice("version:".length).trim() + else if (line.startsWith("releaseDate:")) + releaseDate = line.slice("releaseDate:".length).trim().replace(/^'|'$/g, "") + else if (line.trim().startsWith("- url:")) { + flush() + current = { url: line.trim().slice("- url:".length).trim() } + } else if (indented && current && line.trim().startsWith("sha512:")) + current.sha512 = line.trim().slice("sha512:".length).trim() + else if (indented && current && line.trim().startsWith("size:")) + current.size = Number(line.trim().slice("size:".length).trim()) + else if (indented && current && line.trim().startsWith("blockMapSize:")) + current.blockMapSize = Number(line.trim().slice("blockMapSize:".length).trim()) + else if (!indented && current) flush() + } + flush() + + return { version, files, releaseDate } +} + +function serialize(data: LatestYml) { + const lines = [`version: ${data.version}`, "files:"] + for (const file of data.files) { + lines.push(` - url: ${file.url}`) + lines.push(` sha512: ${file.sha512}`) + lines.push(` size: ${file.size}`) + if (file.blockMapSize) lines.push(` blockMapSize: ${file.blockMapSize}`) + } + lines.push(`releaseDate: '${data.releaseDate}'`) + return lines.join("\n") + "\n" +} + +async function read(subdir: string, filename: string): Promise { + const file = Bun.file(path.join(dir, subdir, filename)) + if (!(await file.exists())) return undefined + return parse(await file.text()) +} + +const output: Record = {} + +// Windows: merge arm64 + x64 into single file +const winX64 = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml") +const winArm64 = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml") +if (winX64 || winArm64) { + const base = winArm64 ?? winX64! + output["latest.yml"] = serialize({ + version: base.version, + files: [...(winArm64?.files ?? []), ...(winX64?.files ?? [])], + releaseDate: base.releaseDate, + }) +} + +// Linux x64: pass through +const linuxX64 = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml") +if (linuxX64) output["latest-linux.yml"] = serialize(linuxX64) + +// Linux arm64: pass through +const linuxArm64 = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml") +if (linuxArm64) output["latest-linux-arm64.yml"] = serialize(linuxArm64) + +// macOS: merge arm64 + x64 into single file +const macX64 = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml") +const macArm64 = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml") +if (macX64 || macArm64) { + const base = macArm64 ?? macX64! + output["latest-mac.yml"] = serialize({ + version: base.version, + files: [...(macArm64?.files ?? []), ...(macX64?.files ?? [])], + releaseDate: base.releaseDate, + }) +} + +// Upload to release +const tag = `v${version}` +const tmp = process.env.RUNNER_TEMP ?? "/tmp" + +for (const [filename, content] of Object.entries(output)) { + const filepath = path.join(tmp, filename) + await Bun.write(filepath, content) + await $`gh release upload ${tag} ${filepath} --clobber --repo ${repo}` + console.log(`uploaded ${filename}`) +} + +console.log("finalized latest yml files") diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts new file mode 100644 index 0000000000000000000000000000000000000000..636850a38757bd6ebba6b01f635a4da8c58e0bf9 --- /dev/null +++ b/packages/desktop/scripts/prebuild.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env bun +import { $ } from "bun" + +import { downloadCliToResources, resolveChannel } from "./utils" + +const channel = resolveChannel() +await $`bun ./scripts/copy-icons.ts ${channel}` +await $`bun ./scripts/copy-metainfo.ts ${channel}` + +await $`cd ../opencode && bun script/build-node.ts` +if (channel === "dev") await downloadCliToResources() diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfa399e4e53799cfc154ac467845783cbf667731 --- /dev/null +++ b/packages/desktop/scripts/predev.ts @@ -0,0 +1,9 @@ +import { $ } from "bun" +import { downloadCliToResources } from "./utils" + +await $`bun run install-electron` + +await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` + +await $`cd ../opencode && bun script/build-node.ts` +await downloadCliToResources() diff --git a/packages/desktop/scripts/prepare.ts b/packages/desktop/scripts/prepare.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dfd5a35cbf8552dd973b8f8733763a345b920bd --- /dev/null +++ b/packages/desktop/scripts/prepare.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env bun +import { Script } from "@opencode-ai/script" + +await import("./prebuild") + +const pkg = await Bun.file("./package.json").json() +pkg.version = Script.version +await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n") +console.log(`Updated package.json version to ${Script.version}`) diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf728758ed143e95dde8af619ba84615ed50e4d5 --- /dev/null +++ b/packages/desktop/scripts/utils.ts @@ -0,0 +1,97 @@ +import { $ } from "bun" +import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const CLI_VERSION = "0.0.0-next-16350" + +export type Channel = "dev" | "beta" | "prod" + +export function resolveChannel(): Channel { + const raw = Bun.env.OPENCODE_CHANNEL + if (raw === "dev" || raw === "beta" || raw === "prod") return raw + return "dev" +} + +export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [ + { + rustTarget: "aarch64-apple-darwin", + package: "@opencode-ai/cli-darwin-arm64", + os: "darwin", + cpu: "arm64", + }, + { + rustTarget: "x86_64-apple-darwin", + package: "@opencode-ai/cli-darwin-x64-baseline", + os: "darwin", + cpu: "x64", + }, + { + rustTarget: "aarch64-pc-windows-msvc", + package: "@opencode-ai/cli-windows-arm64", + os: "win32", + cpu: "arm64", + }, + { + rustTarget: "x86_64-pc-windows-msvc", + package: "@opencode-ai/cli-windows-x64-baseline", + os: "win32", + cpu: "x64", + }, + { + rustTarget: "x86_64-unknown-linux-gnu", + package: "@opencode-ai/cli-linux-x64-baseline", + os: "linux", + cpu: "x64", + }, + { + rustTarget: "aarch64-unknown-linux-gnu", + package: "@opencode-ai/cli-linux-arm64", + os: "linux", + cpu: "arm64", + }, +] + +export const RUST_TARGET = Bun.env.RUST_TARGET + +function nativeTarget() { + const { platform, arch } = process + if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin" + if (platform === "win32") return arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc" + if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu" + throw new Error(`Unsupported platform: ${platform}/${arch}`) +} + +export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) { + const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target) + if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`) + + return binaryConfig +} + +export async function downloadCliToResources() { + const cli = getCurrentCli() + const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) + const dest = windowsify("resources/opencode-cli") + try { + await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` + await copyFile( + join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), + dest, + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + if (process.platform !== "win32") await chmod(dest, 0o755) + if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { + await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}` + } + if (process.platform === "darwin") await $`codesign --force --sign - ${dest}` + + console.log(`Copied ${cli.package} to ${dest}`) +} + +export function windowsify(path: string) { + if (path.endsWith(".exe")) return path + return `${path}${process.platform === "win32" ? ".exe" : ""}` +} diff --git a/packages/desktop/src/main/apps.ts b/packages/desktop/src/main/apps.ts new file mode 100644 index 0000000000000000000000000000000000000000..1aec192360667eb0c913e371d34b4de4dea852b8 --- /dev/null +++ b/packages/desktop/src/main/apps.ts @@ -0,0 +1,136 @@ +import { execFile } from "node:child_process" +import { access, readFile, readdir } from "node:fs/promises" +import { dirname, extname, join } from "node:path" +import util from "node:util" + +const execFilePromise = util.promisify(execFile) + +const exists = (path: string) => + access(path) + .then(() => true) + .catch(() => false) + +export function checkAppExists(appName: string) { + if (process.platform === "win32") return true + if (process.platform === "linux") return true + return checkMacosApp(appName) +} + +export function resolveAppPath(appName: string) { + if (process.platform !== "win32") return appName + return resolveWindowsAppPath(appName) +} + +async function checkMacosApp(appName: string) { + const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`] + + const home = process.env.HOME + if (home) locations.push(`${home}/Applications/${appName}.app`) + + for (const location of locations) { + if (await exists(location)) return true + } + + return execFilePromise("which", [appName]) + .then(() => true) + .catch(() => false) +} + +async function resolveWindowsAppPath(appName: string): Promise { + let output: string + try { + output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString()) + } catch { + return null + } + + const paths = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}` + + const exe = paths.find((path) => hasExt(path, "exe")) + if (exe) return exe + + const resolveCmd = async (path: string) => { + const content = await readFile(path, "utf8") + for (const token of content.split('"').map((value: string) => value.trim())) { + const lower = token.toLowerCase() + if (!lower.includes(".exe")) continue + + const index = lower.indexOf("%~dp0") + if (index >= 0) { + const base = dirname(path) + const suffix = token.slice(index + 5) + const resolved = suffix + .replace(/\//g, "\\") + .split("\\") + .filter((part: string) => part && part !== ".") + .reduce((current: string, part: string) => { + if (part === "..") return dirname(current) + return join(current, part) + }, base) + + if (await exists(resolved)) return resolved + } + + if (await exists(token)) return token + } + + return null + } + + for (const path of paths) { + if (hasExt(path, "cmd") || hasExt(path, "bat")) { + const resolved = await resolveCmd(path) + if (resolved) return resolved + } + + if (!extname(path)) { + const cmd = `${path}.cmd` + if (await exists(cmd)) { + const resolved = await resolveCmd(cmd) + if (resolved) return resolved + } + + const bat = `${path}.bat` + if (await exists(bat)) { + const resolved = await resolveCmd(bat) + if (resolved) return resolved + } + } + } + + const key = appName + .split("") + .filter((value: string) => /[a-z0-9]/i.test(value)) + .map((value: string) => value.toLowerCase()) + .join("") + + if (key) { + for (const path of paths) { + const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))] + for (const dir of dirs) { + try { + for (const entry of await readdir(dir)) { + const candidate = join(dir, entry) + if (!hasExt(candidate, "exe")) continue + const stem = entry.replace(/\.exe$/i, "") + const name = stem + .split("") + .filter((value: string) => /[a-z0-9]/i.test(value)) + .map((value: string) => value.toLowerCase()) + .join("") + if (name.includes(key) || key.includes(name)) return candidate + } + } catch { + continue + } + } + } + } + + return paths[0] ?? null +} diff --git a/packages/desktop/src/main/attachment-picker.test.ts b/packages/desktop/src/main/attachment-picker.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2d5d6b56cb247729853e841a06e676da8806e97 --- /dev/null +++ b/packages/desktop/src/main/attachment-picker.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + assertAttachmentBudget, + createPickedFileAuthorizations, + MAX_ATTACHMENT_BYTES, + readAttachment, +} from "./attachment-picker" + +describe("assertAttachmentBudget", () => { + test("accepts selections within the media ingest limit", () => { + expect(() => + assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES / 2 }, { size: MAX_ATTACHMENT_BYTES / 2 }]), + ).not.toThrow() + }) + + test("rejects the selection before files are read when its total exceeds the limit", () => { + expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES }, { size: 1 }])).toThrow("20 MB limit") + }) + + test("reads an approved file through a bounded buffer", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-")) + const file = join(directory, "example.txt") + try { + await writeFile(file, "lorem ipsum") + expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("rejects an oversized file before allocating its contents", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-")) + const file = join(directory, "oversized.txt") + try { + await writeFile(file, "") + await truncate(file, MAX_ATTACHMENT_BYTES + 1) + await expect(readAttachment(file)).rejects.toThrow("20 MB limit") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) + +describe("picked file authorizations", () => { + const read = async (path: string) => new TextEncoder().encode(path).buffer + + test("keeps concurrent picker selections isolated", async () => { + const authorizations = createPickedFileAuthorizations(read) + const first = authorizations.add(1, ["a.txt", "b.txt"]) + const second = authorizations.add(1, ["c.txt"]) + + expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt") + expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt") + expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt") + }) + + test("releases unread files for one picker without affecting another", async () => { + const authorizations = createPickedFileAuthorizations(read) + const first = authorizations.add(1, ["a.txt"]) + const second = authorizations.add(1, ["b.txt"]) + authorizations.release(1, first) + + await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected") + expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt") + }) + + test("keeps picker tokens scoped to their renderer", async () => { + const authorizations = createPickedFileAuthorizations(read) + const token = authorizations.add(1, ["a.txt"]) + + await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected") + }) + + test("charges actual reads against the selection budget", async () => { + const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => { + if (6 > maxBytes) throw new Error("budget exceeded") + return new ArrayBuffer(6) + }, 10) + const token = authorizations.add(1, ["a.txt", "b.txt"]) + + await authorizations.read(1, token, "a.txt") + await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded") + }) +}) diff --git a/packages/desktop/src/main/attachment-picker.ts b/packages/desktop/src/main/attachment-picker.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2080cfe4d5e63716d16b82a8e40b45d4df46f11 --- /dev/null +++ b/packages/desktop/src/main/attachment-picker.ts @@ -0,0 +1,57 @@ +import { randomUUID } from "node:crypto" +import { open } from "node:fs/promises" +import { nativeT } from "./native-translations" + +export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 + +export function createPickedFileAuthorizations( + read: (path: string, maxBytes: number) => Promise = readAttachment, + budget = MAX_ATTACHMENT_BYTES, +) { + const selections = new Map; remaining: number }>() + + return { + add(sender: number, paths: string[]) { + const token = randomUUID() + selections.set(token, { sender, paths: new Set(paths), remaining: budget }) + return token + }, + async read(sender: number, token: string, path: string) { + const selection = selections.get(token) + if (selection?.sender !== sender || !selection.paths.delete(path)) + throw new Error(nativeT("desktop.picker.error.notSelected")) + const bytes = await read(path, selection.remaining) + selection.remaining -= bytes.byteLength + if (selection.paths.size === 0) selections.delete(token) + return bytes + }, + release(sender: number, token: string) { + if (selections.get(token)?.sender === sender) selections.delete(token) + }, + } +} + +export function assertAttachmentBudget(files: { size: number }[]) { + const total = files.reduce((sum, file) => sum + file.size, 0) + if (total <= MAX_ATTACHMENT_BYTES) return + throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) +} + +export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) { + const file = await open(filePath, "r") + try { + const info = await file.stat() + if (info.size > maxBytes) + throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) + const bytes = Buffer.allocUnsafe(info.size) + let offset = 0 + while (offset < info.size) { + const result = await file.read(bytes, offset, info.size - offset, offset) + if (result.bytesRead === 0) break + offset += result.bytesRead + } + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer + } finally { + await file.close() + } +} diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts new file mode 100644 index 0000000000000000000000000000000000000000..66602d51f116d2d9a5c0ec2e41673b4ef28f96aa --- /dev/null +++ b/packages/desktop/src/main/background-cli.ts @@ -0,0 +1,125 @@ +import { execFile } from "node:child_process" +import { existsSync } from "node:fs" +import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { app } from "electron" + +const execFileAsync = promisify(execFile) +const root = dirname(fileURLToPath(import.meta.url)) +const stateHome = process.env.XDG_STATE_HOME +const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"] + +type Logger = { + log(message: string, meta?: Record): void + error(message: string, meta?: Record): void +} + +export async function startBackgroundCli(logger: Logger, shellStateHome?: string) { + const bundled = app.isPackaged + ? join(process.resourcesPath, executableName()) + : join(root, "../../resources", executableName()) + logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) + const version = await run(bundled, ["--version"], logger) + const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled + + const candidates = [ + ...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]), + ].filter((candidate) => candidate === undefined || existsSync(candidate)) + const discovered = await Promise.all( + candidates.map(async (candidate) => ({ + stateHome: candidate, + url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })), + })), + ) + const found = discovered.find((candidate) => candidate.url !== undefined) + logger.log("v2 CLI background instance checked", { + detected: Boolean(found), + ...endpoint(found?.url), + }) + + const daemonStateHome = found?.stateHome ?? stateHome + const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome }) + const password = await run(binary, ["service", "get", "password"], logger, { + redact: true, + stateHome: daemonStateHome, + }) + logger.log("v2 CLI background service ready", { + existing: Boolean(found), + username: "opencode", + ...endpoint(url), + }) + return { + url, + username: "opencode", + password, + } +} + +async function installCli(source: string, version: string, logger: Logger) { + const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-")) + const destination = join(directory, executableName()) + if (existsSync(destination)) { + logger.log("v2 CLI staged executable reused", { path: destination, version }) + return destination + } + + const temp = destination + `.${process.pid}.tmp` + await mkdir(directory, { recursive: true }) + await copyFile(source, temp) + if (process.platform !== "win32") await chmod(temp, 0o755) + await rename(temp, destination).catch(async (error) => { + await rm(temp, { force: true }) + throw error + }) + logger.log("v2 CLI executable staged", { source, path: destination, version }) + return destination +} + +async function run( + binary: string, + args: string[], + logger: Logger, + options: { redact?: boolean; stateHome?: string } = {}, +) { + logger.log("v2 CLI command started", { binary, args }) + const env = { ...process.env } + if (options.stateHome === undefined) delete env.XDG_STATE_HOME + else env.XDG_STATE_HOME = options.stateHome + return execFileAsync(binary, args, { env, windowsHide: true }).then( + (result) => { + const stdout = result.stdout.trim() + const stderr = result.stderr.trim() + logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr }) + return stdout + }, + (error: unknown) => { + const output = error as { stdout?: string; stderr?: string } + logger.error("v2 CLI command failed", { + args, + error: error instanceof Error ? error.message : String(error), + stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""), + stderr: output.stderr?.trim() ?? "", + }) + throw error + }, + ) +} + +function serviceUrl(status: string) { + if (URL.canParse(status)) return status + if (!status.startsWith("running ")) return + const url = status.slice("running ".length).trim() + return URL.canParse(url) ? url : undefined +} + +function endpoint(url: string | undefined) { + if (!url || !URL.canParse(url)) return {} + const parsed = new URL(url) + return { url, hostname: parsed.hostname, port: parsed.port } +} + +function executableName() { + return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli" +} diff --git a/packages/desktop/src/main/constants.ts b/packages/desktop/src/main/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..258deb7c6de731ccc005bbf7b0000f80b29caa5f --- /dev/null +++ b/packages/desktop/src/main/constants.ts @@ -0,0 +1,7 @@ +import { app } from "electron" + +type Channel = "dev" | "beta" | "prod" +const raw = import.meta.env.OPENCODE_CHANNEL +export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev" + +export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop/src/main/debug.ts b/packages/desktop/src/main/debug.ts new file mode 100644 index 0000000000000000000000000000000000000000..373aec237f7b94932de61a26347a1f10210e7c87 --- /dev/null +++ b/packages/desktop/src/main/debug.ts @@ -0,0 +1,95 @@ +import type { WebContents } from "electron" + +const focusDebuggerOwners = new WeakSet() +const forcedFocusNodes = new WeakMap() +const focusableSelector = ` + a[href], + button:not([disabled]), + input:not([disabled]), + select:not([disabled]), + textarea:not([disabled]), + summary, + [contenteditable="true"], + [tabindex]:not([tabindex="-1"]) +` + +export async function setForceFocus(contents: WebContents, enabled: boolean) { + const debuggerApi = contents.debugger + if (!debuggerApi.isAttached()) { + if (!enabled) { + focusDebuggerOwners.delete(contents) + forcedFocusNodes.delete(contents) + return + } + debuggerApi.attach("1.3") + focusDebuggerOwners.add(contents) + debuggerApi.once("detach", () => { + focusDebuggerOwners.delete(contents) + forcedFocusNodes.delete(contents) + }) + } + + if (!enabled) { + await Promise.allSettled( + (forcedFocusNodes.get(contents) ?? []).map((nodeId) => + debuggerApi.sendCommand("CSS.forcePseudoState", { + nodeId, + forcedPseudoClasses: [], + }), + ), + ) + forcedFocusNodes.delete(contents) + if (!focusDebuggerOwners.delete(contents)) return + debuggerApi.detach() + return + } + + await debuggerApi.sendCommand("DOM.enable") + await debuggerApi.sendCommand("CSS.enable") + const document: unknown = await debuggerApi.sendCommand("DOM.getDocument", { + depth: -1, + pierce: true, + }) + const nodes: unknown = await debuggerApi.sendCommand("DOM.querySelectorAll", { + nodeId: readDocumentNodeId(document), + selector: focusableSelector, + }) + const nodeIds = readNodeIds(nodes) + forcedFocusNodes.set(contents, [...new Set([...(forcedFocusNodes.get(contents) ?? []), ...nodeIds])]) + await Promise.allSettled( + nodeIds.map((nodeId) => + debuggerApi.sendCommand("CSS.forcePseudoState", { + nodeId, + forcedPseudoClasses: ["focus", "focus-visible"], + }), + ), + ) +} + +function readDocumentNodeId(value: unknown) { + if ( + !value || + typeof value !== "object" || + !("root" in value) || + !value.root || + typeof value.root !== "object" || + !("nodeId" in value.root) || + typeof value.root.nodeId !== "number" + ) { + throw new Error("Invalid DOM.getDocument response") + } + return value.root.nodeId +} + +function readNodeIds(value: unknown) { + if ( + !value || + typeof value !== "object" || + !("nodeIds" in value) || + !Array.isArray(value.nodeIds) || + !value.nodeIds.every((nodeId) => typeof nodeId === "number") + ) { + throw new Error("Invalid DOM.querySelectorAll response") + } + return value.nodeIds +} diff --git a/packages/desktop/src/main/desktop-menu-actions.ts b/packages/desktop/src/main/desktop-menu-actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa15e05e277ff2022732cd9e90dfc93476c132f5 --- /dev/null +++ b/packages/desktop/src/main/desktop-menu-actions.ts @@ -0,0 +1,84 @@ +import { BrowserWindow } from "electron" +import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" +import { createMainWindow, updateTitlebar } from "./windows" + +export type DesktopMenuActionHandlers = Partial<{ + checkForUpdates: () => void + relaunch: () => void +}> + +export function runDesktopMenuAction( + win: BrowserWindow | null, + action: DesktopMenuAction, + handlers: DesktopMenuActionHandlers = {}, +) { + switch (action) { + case "app.checkForUpdates": + handlers.checkForUpdates?.() + return + case "app.relaunch": + handlers.relaunch?.() + return + case "window.new": + createMainWindow() + return + case "window.close": + win?.close() + return + case "window.minimize": + win?.minimize() + return + case "window.toggleMaximize": + if (win?.isMaximized()) { + win.unmaximize() + return + } + win?.maximize() + return + case "view.reload": + win?.reload() + return + case "view.toggleDevTools": + win?.webContents.toggleDevTools() + return + case "view.resetZoom": + setZoom(win, 1) + return + case "view.zoomIn": + setZoom(win, (win?.webContents.getZoomFactor() ?? 1) + 0.2) + return + case "view.zoomOut": + setZoom(win, (win?.webContents.getZoomFactor() ?? 1) - 0.2) + return + case "view.toggleFullscreen": + win?.setFullScreen(!win.isFullScreen()) + return + case "edit.undo": + win?.webContents.undo() + return + case "edit.redo": + win?.webContents.redo() + return + case "edit.cut": + win?.webContents.cut() + return + case "edit.copy": + win?.webContents.copy() + return + case "edit.paste": + win?.webContents.paste() + return + case "edit.delete": + win?.webContents.delete() + return + case "edit.selectAll": + win?.webContents.selectAll() + return + } +} + +function setZoom(win: BrowserWindow | null, value: number) { + if (!win) return + win.webContents.setZoomFactor(Math.min(Math.max(value, 0.2), 10)) + updateTitlebar(win) +} diff --git a/packages/desktop/src/main/draft-store.test.ts b/packages/desktop/src/main/draft-store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..95d4e7aa803b0375c82bff14f81554882866040b --- /dev/null +++ b/packages/desktop/src/main/draft-store.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test" +import { createDesktopDraftStore } from "./draft-store" + +test("flushes the latest buffered draft and stores blobs", () => { + const store = createDesktopDraftStore(":memory:") + store.set("prompt", "first") + store.set("prompt", "latest") + expect(store.get("prompt")).toBe("latest") + store.flush() + expect(store.get("prompt")).toBe("latest") + + const bytes = new TextEncoder().encode("image") + const id = store.putBlob(bytes) + expect(store.getBlob(id)).toEqual(bytes) + store.close() +}) diff --git a/packages/desktop/src/main/draft-store.ts b/packages/desktop/src/main/draft-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..839eff26afe682f771eea9e30c55fc0af78cfc08 --- /dev/null +++ b/packages/desktop/src/main/draft-store.ts @@ -0,0 +1,82 @@ +import { createHash } from "node:crypto" +import { DatabaseSync } from "node:sqlite" +import { eq } from "drizzle-orm" +import { drizzle } from "drizzle-orm/node-sqlite" +import { blob, sqliteTable, text } from "drizzle-orm/sqlite-core" + +const documents = sqliteTable("document", { + key: text().primaryKey(), + value: text().notNull(), +}) +const blobs = sqliteTable("blob", { + id: text().primaryKey(), + data: blob({ mode: "buffer" }).notNull(), +}) + +export function createDesktopDraftStore(filename: string) { + const native = new DatabaseSync(filename) + native.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS blob (id TEXT PRIMARY KEY, data BLOB NOT NULL);", + ) + const db = drizzle({ client: native }) + const used = new Set() + db.select({ value: documents.value }) + .from(documents) + .all() + .forEach(({ value }) => + JSON.parse(value, (_key, item) => { + if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id) + return item + }), + ) + db.select({ id: blobs.id }) + .from(blobs) + .all() + .filter(({ id }) => !used.has(id)) + .forEach(({ id }) => db.delete(blobs).where(eq(blobs.id, id)).run()) + const pending = new Map() + let timer: ReturnType | undefined + const flush = () => { + if (timer) clearTimeout(timer) + timer = undefined + const writes = [...pending] + pending.clear() + db.transaction((tx) => { + writes.forEach(([key, value]) => { + if (value === null) tx.delete(documents).where(eq(documents.key, key)).run() + else + tx.insert(documents) + .values({ key, value }) + .onConflictDoUpdate({ target: documents.key, set: { value } }) + .run() + }) + }) + } + const schedule = () => { + if (!timer) timer = setTimeout(flush, 500) + } + return { + get: (key: string) => + pending.has(key) + ? (pending.get(key) ?? null) + : (db.select({ value: documents.value }).from(documents).where(eq(documents.key, key)).get()?.value ?? null), + set(key: string, value: string | null) { + pending.set(key, value) + schedule() + }, + putBlob(data: Uint8Array) { + const id = createHash("sha256").update(data).digest("hex") + db.insert(blobs) + .values({ id, data: Buffer.from(data) }) + .onConflictDoNothing() + .run() + return id + }, + getBlob: (id: string) => db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null, + flush, + close() { + flush() + native.close() + }, + } +} diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..d69e6feec25756297a9722ba89aa8d5a4082a637 --- /dev/null +++ b/packages/desktop/src/main/env.d.ts @@ -0,0 +1,19 @@ +interface ImportMetaEnv { + readonly OPENCODE_CHANNEL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare module "virtual:opencode-server" { + export namespace Server { + export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen + export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener + } + export namespace Config { + export const get: typeof import("../../../opencode/dist/types/src/node").Config.get + export type Info = import("../../../opencode/dist/types/src/node").Config.Info + } + export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap +} diff --git a/packages/desktop/src/main/external-url.test.ts b/packages/desktop/src/main/external-url.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e193865e97662606579b6a1d4db25b360987a06e --- /dev/null +++ b/packages/desktop/src/main/external-url.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { resolve } from "node:path" +import { pathToFileURL } from "node:url" +import { resolveExternalURL, resolveLocalFilePath } from "./external-url" + +describe("external URLs", () => { + test("opens web URLs externally", () => { + expect(resolveExternalURL("https://example.com/a?b=c")).toBe("https://example.com/a?b=c") + expect(resolveExternalURL("http://example.com")).toBe("http://example.com/") + }) + + test("opens mail links externally", () => { + expect(resolveExternalURL("mailto:hello@opencode.ai")).toBe("mailto:hello@opencode.ai") + }) + + test("rejects file URLs and unsupported protocols", () => { + expect(resolveExternalURL("file:///tmp/index.html")).toBeUndefined() + expect(resolveExternalURL("javascript:alert(1)")).toBeUndefined() + expect(resolveExternalURL("data:text/html,hello")).toBeUndefined() + expect(resolveExternalURL("not a url")).toBeUndefined() + }) + + test("resolves only local file URLs", () => { + const path = resolve("example.html") + expect(resolveLocalFilePath(pathToFileURL(path).href)).toBe(path) + expect(resolveLocalFilePath("file://example.com/share/index.html")).toBeUndefined() + expect(resolveLocalFilePath("https://example.com/index.html")).toBeUndefined() + }) +}) diff --git a/packages/desktop/src/main/external-url.ts b/packages/desktop/src/main/external-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..9430a791b9ca21eed29243f2c2c8d16c39cbb66b --- /dev/null +++ b/packages/desktop/src/main/external-url.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from "node:url" + +export function resolveExternalURL(value: string) { + if (!URL.canParse(value)) return undefined + const url = new URL(value) + if (url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:") return url.href + return undefined +} + +export function resolveLocalFilePath(value: string) { + if (!URL.canParse(value)) return undefined + const url = new URL(value) + if (url.protocol !== "file:" || url.hostname) return undefined + try { + return fileURLToPath(url) + } catch { + return undefined + } +} diff --git a/packages/desktop/src/main/index.test.ts b/packages/desktop/src/main/index.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..338c7fd26a22fe639799fd3888803f0eab6db12f --- /dev/null +++ b/packages/desktop/src/main/index.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Cause, Deferred, Effect, Exit, Fiber } from "effect" +import { forwardInitializationFailure } from "./initialization" + +describe("desktop initialization", () => { + const failure = new Error("sidecar startup failed") + const expectFailure = (exit: Exit.Exit) => { + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(Cause.squash(exit.cause)).toBe(failure) + } + + test("forwards loading task failures before renderer initialization", () => { + const exit = Effect.runSync( + Effect.gen(function* () { + const initialization = yield* Deferred.make() + yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit) + return yield* Deferred.await(initialization).pipe(Effect.exit) + }), + ) + + expectFailure(exit) + }) + + test("forwards loading task failures while renderer initialization waits", () => { + const exit = Effect.runSync( + Effect.gen(function* () { + const initialization = yield* Deferred.make() + const waiting = yield* Deferred.await(initialization).pipe(Effect.exit, Effect.forkChild) + yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit) + return yield* Fiber.join(waiting) + }), + ) + + expectFailure(exit) + }) +}) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..183fc634db0160296dd2984e701b8396cfe5f64c --- /dev/null +++ b/packages/desktop/src/main/index.ts @@ -0,0 +1,424 @@ +import { randomUUID } from "node:crypto" +import { mkdirSync, rmSync } from "node:fs" +import * as http from "node:http" +import { createServer } from "node:net" +import { homedir, tmpdir } from "node:os" +import { join } from "node:path" +import { getCACertificates, setDefaultCACertificates } from "node:tls" +import type { Event } from "electron" +import { app, BrowserWindow } from "electron" + +import { Deferred, Effect, Fiber } from "effect" +import contextMenu from "electron-context-menu" + +import type { ServerReadyData } from "../preload/types" +import { checkAppExists, resolveAppPath } from "./apps" +import { CHANNEL } from "./constants" +import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc" +import { forwardInitializationFailure } from "./initialization" +import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging" +import { createMenu } from "./menu" +import { + finishFirstLaunchOnboarding, + initializeOldLayoutEligibility, + isFirstLaunchOnboardingPending, + isOldLayoutEligible, +} from "./onboarding" +import { + getDefaultServerUrl, + preferAppEnv, + setDefaultServerUrl, + spawnLocalServer, + type SidecarListener, +} from "./server" +import { setupAutoUpdater, showUpdaterDialog } from "./updater" +import { safeWebContentsURL } from "./window-state" +import { + getLastFocusedWindow, + registerRendererProtocol, + setRelaunchHandler, + setAppQuitting, + setBackgroundColor, + setDockIcon, + restoreMainWindows, +} from "./windows" +import { createWslServersController } from "./wsl/servers" +import { registerWslIpcHandlers } from "./wsl/ipc" +import { spawnWslSidecar } from "./wsl/sidecar" +import { migrate } from "./migrate" +import { cleanupStoreFiles } from "./store-cleanup" +import { startBackgroundCli } from "./background-cli" +import { setNativeTranslations } from "./native-translations" + +const APP_NAMES: Record = { + dev: "OpenCode Dev", + beta: "OpenCode Beta", + prod: "OpenCode", +} +const APP_IDS: Record = { + dev: "ai.opencode.desktop.dev", + beta: "ai.opencode.desktop.beta", + prod: "ai.opencode.desktop", +} +const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" +const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1" +const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" + +let logger: ReturnType +let server: SidecarListener | null = null + +const pendingDeepLinks: string[] = [] + +function useEnvProxy() { + try { + // Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2. + ;(http as any).setGlobalProxyFromEnv() + } catch (error) { + logger.warn("failed to load proxy environment", error) + } +} + +function emitDeepLinks(urls: string[]) { + if (urls.length === 0) return + pendingDeepLinks.push(...urls) + const win = getLastFocusedWindow() + if (win) sendDeepLinks(win, urls) +} + +async function killSidecar() { + if (!server) return + const current = server + server = null + await current.stop() +} + +function ensureLoopbackNoProxy() { + const loopback = ["127.0.0.1", "localhost", "::1"] + const upsert = (key: string) => { + const items = (process.env[key] ?? "") + .split(",") + .map((value: string) => value.trim()) + .filter((value: string) => Boolean(value)) + + for (const host of loopback) { + if (items.some((value: string) => value.toLowerCase() === host)) continue + items.push(host) + } + + process.env[key] = items.join(",") + } + + upsert("NO_PROXY") + upsert("no_proxy") +} + +const main = Effect.gen(function* () { + contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) + + // on macOS apps run in `/` which can cause issues with ripgrep + try { + process.chdir(homedir()) + } catch {} + + process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + + const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" + const onboardingTestRoot = ((): string | undefined => { + if (!TEST_ONBOARDING) return + + const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + rmSync(root, { recursive: true, force: true }) + ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => + mkdirSync(join(root, dir), { recursive: true }), + ) + process.env.OPENCODE_DB = ":memory:" + process.env.XDG_DATA_HOME = join(root, "data") + process.env.XDG_CONFIG_HOME = join(root, "config") + process.env.XDG_CACHE_HOME = join(root, "cache") + process.env.XDG_STATE_HOME = join(root, "state") + return root + })() + app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") + app.setAppUserModelId(appId) + app.setPath( + "userData", + onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), + ) + if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) + initializeOldLayoutEligibility(app.getPath("userData")) + logger = initLogging() + initCrashReporter() + + const wslServers = createWslServersController( + app.getVersion(), + async (distro) => { + logger.log("spawning wsl sidecar", { distro }) + return spawnWslSidecar(distro, { + onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), + }) + }, + { + logger: { + log: (message, meta) => logger.log(message, meta), + error: (message, meta) => logger.error(message, meta), + }, + }, + ) + const stopSidecars = async () => { + await killSidecar() + wslServers.stopAll() + } + const relaunch = () => { + setAppQuitting() + void stopSidecars().finally(() => { + app.relaunch() + app.quit() + }) + } + + try { + setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) + } catch (error) { + logger.warn("failed to load system certificates", error) + } + + logger.log("app starting", { + version: app.getVersion(), + packaged: app.isPackaged, + onboardingTest: Boolean(onboardingTestRoot), + }) + + ensureLoopbackNoProxy() + useEnvProxy() + app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + const features = app.commandLine.getSwitchValue("enable-features") + app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature) + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") + + if (!app.requestSingleInstanceLock()) { + app.quit() + return + } + + const shellEnv = preferAppEnv(app.getPath("userData")) + + app.on("second-instance", (_event: Event, argv: string[]) => { + const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) + if (urls.length) { + logger.log("deep link received via second-instance", { urls }) + emitDeepLinks(urls) + } + const win = getLastFocusedWindow() + if (win) { + win.show() + win.focus() + } + }) + + app.on("open-url", (event: Event, url: string) => { + event.preventDefault() + logger.log("deep link received via open-url", { url }) + emitDeepLinks([url]) + }) + + app.on("before-quit", () => { + setAppQuitting() + void stopSidecars() + }) + + app.on("will-quit", () => { + setAppQuitting() + void stopSidecars() + }) + + app.on("child-process-gone", (_event, details) => { + writeLog("utility", "child process gone", { details }, "error") + }) + + app.on("render-process-gone", (_event, webContents, details) => { + writeLog("window", "app render process gone", { url: safeWebContentsURL(webContents), details }, "error") + }) + + setRelaunchHandler(() => { + relaunch() + }) + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + setAppQuitting() + void stopSidecars().finally(() => app.quit()) + }) + } + + const serverReady = Deferred.makeUnsafe() + + yield* Effect.promise(() => app.whenReady()) + + if (!TEST_ONBOARDING) migrate() + yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe( + Effect.tap((result) => + Effect.sync(() => { + if (result.deleted.length === 0) return + logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }) + }), + ), + Effect.catch((error) => + Effect.sync(() => { + logger.warn("failed to clean scoped store files", error) + }), + ), + ) + app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() + setDockIcon() + const updater = setupAutoUpdater(stopSidecars) + const menuDeps = { + trigger: (id: string) => { + const win = getLastFocusedWindow() + if (win) sendMenuCommand(win, id) + }, + checkForUpdates: () => void showUpdaterDialog(updater, true), + relaunch, + } + registerIpcHandlers({ + killSidecar: () => killSidecar(), + relaunch, + awaitInitialization: Effect.fnUntraced( + function* () { + logger.log("awaiting server ready") + const res = yield* Deferred.await(serverReady) + logger.log("server ready", { url: res.url }) + return res + }, + (e) => Effect.runPromise(e), + ), + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + getDefaultServerUrl: () => getDefaultServerUrl(), + setDefaultServerUrl: (url) => setDefaultServerUrl(url), + isFirstLaunchOnboardingPending, + finishFirstLaunchOnboarding, + isOldLayoutEligible, + getDisplayBackend: async () => null, + setDisplayBackend: async () => undefined, + checkAppExists: (appName) => checkAppExists(appName), + resolveAppPath: async (appName) => resolveAppPath(appName), + updater, + showUpdater: () => showUpdaterDialog(updater, true), + setBackgroundColor: (color) => setBackgroundColor(color), + exportDebugLogs: () => exportDebugLogs(), + recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"), + setNativeTranslations: (bundle) => { + if (setNativeTranslations(bundle)) createMenu(menuDeps) + }, + }) + registerWslIpcHandlers(wslServers) + void updater.start() + const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) + updateTimer.unref() + app.once("will-quit", () => clearInterval(updateTimer)) + yield* Effect.promise(() => startNetLog()).pipe( + Effect.catch((error) => + Effect.sync(() => { + logger.warn("failed to start net log", error) + }), + ), + ) + + const loadingTask = yield* Effect.gen(function* () { + logger.log("sidecar connection started", { version: SIDECAR_VERSION }) + + ensureLoopbackNoProxy() + useEnvProxy() + + if (SIDECAR_VERSION === "v2") { + logger.log("spawning v2 sidecar") + const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) + yield* Deferred.succeed(serverReady, { + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, + }) + + if (process.platform === "win32") { + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) + } + + logger.log("loading task finished") + return + } + + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + + const res = yield* Deferred.make() + const socket = createServer() + socket.on("error", (e) => Deferred.failSync(res, () => e)) + socket.listen(0, "127.0.0.1", () => { + const address = socket.address() + if (typeof address !== "object" || !address) { + socket.close() + Deferred.failSync(res, () => new Error("Failed to get port")) + return + } + const port = address.port + socket.close(() => Effect.runSync(Deferred.succeed(res, port))) + }) + + return yield* Deferred.await(res) + }) + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() + + logger.log("spawning sidecar", { url }) + const { listener, health } = yield* Effect.promise(() => + spawnLocalServer(hostname, port, password, { + userDataPath: app.getPath("userData"), + onStdout: (message) => writeLog("server", "stdout", { message }), + onStderr: (message) => writeLog("server", "stderr", { message }, "warn"), + onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"), + }), + ) + server = listener + yield* Deferred.succeed(serverReady, { + url, + username: "opencode", + password, + }) + + if (process.platform === "win32") { + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) + } + + yield* Effect.promise(() => health.wait).pipe( + Effect.timeout("30 seconds"), + Effect.catch((e) => + Effect.sync(() => { + logger.error("sidecar health check failed", e.toString()) + }), + ), + ) + + logger.log("loading task finished") + }).pipe(forwardInitializationFailure(serverReady), Effect.forkChild) + + yield* Fiber.await(loadingTask) + + app.on("window-all-closed", () => { + if (process.platform === "darwin") return + app.quit() + }) + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length > 0) return + restoreMainWindows() + }) + + const windows = restoreMainWindows() + if (windows.length) createMenu(menuDeps) +}) + +Effect.runFork(main) diff --git a/packages/desktop/src/main/initialization.ts b/packages/desktop/src/main/initialization.ts new file mode 100644 index 0000000000000000000000000000000000000000..476abac7f5961452c593ab3834c0de78aa7d0b80 --- /dev/null +++ b/packages/desktop/src/main/initialization.ts @@ -0,0 +1,6 @@ +import { Deferred, Effect } from "effect" + +export function forwardInitializationFailure(initialization: Deferred.Deferred) { + return (effect: Effect.Effect) => + effect.pipe(Effect.tapCause((cause) => Deferred.failCause(initialization, cause))) +} diff --git a/packages/desktop/src/main/install-state.test.ts b/packages/desktop/src/main/install-state.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e85d11815e51caa946137b3339668e01dce5de73 --- /dev/null +++ b/packages/desktop/src/main/install-state.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { hasExistingAppState } from "./install-state" + +const file = (name: string) => ({ name, isDirectory: () => false }) +const directory = (name: string) => ({ name, isDirectory: () => true }) + +describe("hasExistingAppState", () => { + test("ignores files Electron may create on a fresh install", () => { + expect(hasExistingAppState([])).toBe(false) + expect(hasExistingAppState([file("Local State"), directory("Crashpad")])).toBe(false) + }) + + test("recognizes state written by an earlier OpenCode launch", () => { + expect(hasExistingAppState([file("opencode.settings")])).toBe(true) + expect(hasExistingAppState([file("opencode.global.dat")])).toBe(true) + expect(hasExistingAppState([file("window-state-abc.json")])).toBe(true) + expect(hasExistingAppState([directory("opencode")])).toBe(true) + }) +}) diff --git a/packages/desktop/src/main/install-state.ts b/packages/desktop/src/main/install-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..32f1df3640ce53c83acdb92666f9d5c9f2812041 --- /dev/null +++ b/packages/desktop/src/main/install-state.ts @@ -0,0 +1,8 @@ +export function hasExistingAppState(entries: Array<{ name: string; isDirectory: () => boolean }>) { + return entries.some((entry) => { + if (entry.name === "opencode.settings") return true + if (entry.name.endsWith(".dat")) return true + if (/^window-state-.+\.json$/.test(entry.name)) return true + return entry.isDirectory() && entry.name === "opencode" + }) +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8abfc1ceb3b7e1f592a54ad4795c6fe2fa5bbed --- /dev/null +++ b/packages/desktop/src/main/ipc.ts @@ -0,0 +1,308 @@ +import { execFile } from "node:child_process" +import { stat } from "node:fs/promises" +import { basename, join } from "node:path" +import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron" +import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" +import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" +import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" + +import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types" +import { runDesktopMenuAction } from "./desktop-menu-actions" +import { setForceFocus } from "./debug" +import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" +import { getStore, removeStoreFileIfEmpty } from "./store" +import { + getPinchZoomEnabled, + getWindowID, + openExternalURL, + openLocalFileURL, + setPinchZoomEnabled, + setTitlebar, + updateTitlebar, +} from "./windows" +import type { UpdaterController } from "./updater-controller" +import { createUpdaterSubscriptions } from "./updater-subscriptions" +import { createDesktopDraftStore } from "./draft-store" +import { nativeT } from "./native-translations" + +const pickerFilters = (ext?: string[]) => { + if (!ext || ext.length === 0) return undefined + return [{ name: nativeT("desktop.dialog.files"), extensions: ext }] +} + +const pickedFiles = createPickedFileAuthorizations() + +type Deps = { + killSidecar: () => Promise | void + relaunch: () => void + awaitInitialization: () => Promise + consumeInitialDeepLinks: () => Promise | string[] + getDefaultServerUrl: () => Promise | string | null + setDefaultServerUrl: (url: string | null) => Promise | void + isFirstLaunchOnboardingPending: () => Promise | boolean + finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise | string | null + isOldLayoutEligible: () => Promise | boolean + getDisplayBackend: () => Promise + setDisplayBackend: (backend: string | null) => Promise | void + checkAppExists: (appName: string) => Promise | boolean + resolveAppPath: (appName: string) => Promise + updater: UpdaterController + showUpdater: () => Promise | void + setBackgroundColor: (color: string) => void + exportDebugLogs: () => Promise + recordFatalRendererError: (error: FatalRendererError) => Promise | void + setNativeTranslations: (bundle: DesktopNativeBundle) => void +} + +export function registerIpcHandlers(deps: Deps) { + const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite")) + const updaterSubscriptions = createUpdaterSubscriptions() + app.once("will-quit", updaterSubscriptions.clear) + app.on("before-quit", () => drafts.flush()) + app.once("will-quit", () => drafts.close()) + app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush())) + + ipcMain.handle("kill-sidecar", () => deps.killSidecar()) + ipcMain.handle("await-initialization", () => deps.awaitInitialization()) + ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) + ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) + ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => + deps.setDefaultServerUrl(url), + ) + ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending()) + ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) => + deps.finishFirstLaunchOnboarding(createDefaultProject), + ) + ipcMain.handle("is-old-layout-eligible", () => deps.isOldLayoutEligible()) + ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) + ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => + deps.setDisplayBackend(backend), + ) + ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) + ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) + ipcMain.handle("updater-subscribe", (event) => { + const id = event.sender.id + updaterSubscriptions.set( + id, + deps.updater.subscribe((state) => { + if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id) + event.sender.send("updater-state", state) + }), + ) + event.sender.once("destroyed", () => updaterSubscriptions.delete(id)) + }) + ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id)) + ipcMain.handle("updater-check", () => deps.updater.check()) + ipcMain.handle("updater-install", () => deps.updater.install()) + ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) + ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs()) + ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) => + setForceFocus(event.sender, enabled), + ) + ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) => + deps.recordFatalRendererError(error), + ) + ipcMain.handle("set-native-translations", (event: IpcMainInvokeEvent, value: unknown) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) { + throw new Error("Invalid native translation sender") + } + const bundle = parseDesktopNativeBundle(value) + if (!bundle) throw new Error("Invalid native translation bundle") + deps.setNativeTranslations(bundle) + }) + ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { + try { + const store = getStore(name) + const value = store.get(key) + if (value === undefined || value === null) return null + return typeof value === "string" ? value : JSON.stringify(value) + } catch { + return null + } + }) + ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { + getStore(name).set(key, value) + }) + ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { + getStore(name).delete(key) + void removeStoreFileIfEmpty(name) + }) + ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { + getStore(name).clear() + void removeStoreFileIfEmpty(name) + }) + ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { + const store = getStore(name) + return Object.keys(store.store) + }) + ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { + const store = getStore(name) + return Object.keys(store.store).length + }) + ipcMain.handle("draft-get", (_event, key: string) => drafts.get(key)) + ipcMain.handle("draft-set", (_event, key: string, value: string) => drafts.set(key, value)) + ipcMain.handle("draft-delete", (_event, key: string) => drafts.set(key, null)) + ipcMain.handle("draft-blob-put", (_event, data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data))) + ipcMain.handle("draft-blob-get", (_event, id: string) => { + const data = drafts.getBlob(id) + return data ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : null + }) + + ipcMain.handle( + "open-directory-picker", + async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"], + title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"), + defaultPath: opts?.defaultPath, + }) + if (result.canceled) return null + return opts?.multiple ? result.filePaths : result.filePaths[0] + }, + ) + + ipcMain.handle( + "open-file-picker", + async ( + event: IpcMainInvokeEvent, + opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] }, + ) => { + const result = await dialog.showOpenDialog({ + properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], + title: opts?.title ?? nativeT("desktop.dialog.chooseFile"), + defaultPath: opts?.defaultPath, + filters: pickerFilters(opts?.extensions), + }) + if (result.canceled) return null + const files = await Promise.all( + result.filePaths.map(async (filePath) => ({ + path: filePath, + name: basename(filePath), + size: (await stat(filePath)).size, + })), + ) + assertAttachmentBudget(files) + const token = pickedFiles.add(event.sender.id, result.filePaths) + return { token, files } + }, + ) + + ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => { + return pickedFiles.read(event.sender.id, token, filePath) + }) + + ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => { + pickedFiles.release(event.sender.id, token) + }) + + ipcMain.handle( + "save-file-picker", + async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => { + const result = await dialog.showSaveDialog({ + title: opts?.title ?? nativeT("desktop.dialog.saveFile"), + defaultPath: opts?.defaultPath, + }) + if (result.canceled) return null + return result.filePath ?? null + }, + ) + + ipcMain.on("open-external", (_event: IpcMainEvent, url: string) => { + openExternalURL(url) + }) + + ipcMain.on("open-local-file", (_event: IpcMainEvent, url: string) => { + openLocalFileURL(url) + }) + + ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { + if (!app) return shell.openPath(path) + await new Promise((resolve, reject) => { + const [cmd, args] = + process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const) + execFile(cmd, args, (err) => (err ? reject(err) : resolve())) + }) + }) + + ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => { + const exists = await stat(path).then( + () => true, + () => false, + ) + if (!exists) return false + shell.showItemInFolder(path) + return true + }) + + ipcMain.handle("read-clipboard-image", () => { + const image = clipboard.readImage() + if (image.isEmpty()) return null + const buffer = image.toPNG().buffer + const size = image.getSize() + return { buffer, width: size.width, height: size.height } + }) + + ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) throw new Error("Window not found") + const id = getWindowID(win) + if (!id) throw new Error("Window ID not found") + return id + }) + + ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + return win?.isFocused() ?? false + }) + + ipcMain.handle("get-window-fullscreen", (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + return win?.isFullScreen() ?? false + }) + + ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + win?.focus() + }) + + ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + win?.show() + }) + + ipcMain.on("relaunch", () => { + deps.relaunch() + }) + + ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) + ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => { + event.sender.setZoomFactor(factor) + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + updateTitlebar(win) + }) + ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled()) + ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => { + setPinchZoomEnabled(enabled) + }) + ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + setTitlebar(win, theme) + }) + ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => { + runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, { + checkForUpdates: () => void deps.showUpdater(), + relaunch: deps.relaunch, + }) + }) +} + +export function sendMenuCommand(win: BrowserWindow, id: string) { + win.webContents.send("menu-command", id) +} + +export function sendDeepLinks(win: BrowserWindow, urls: string[]) { + win.webContents.send("deep-link", urls) +} diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8fc260cb874065595030544e9fbd961bd35bc02 --- /dev/null +++ b/packages/desktop/src/main/logging.ts @@ -0,0 +1,210 @@ +import { MainLogger } from "electron-log" +import log from "electron-log/main.js" +import { app, crashReporter, netLog, shell } from "electron" +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs" +import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js" +import { dirname, join } from "node:path" +import { homedir } from "node:os" + +const MAX_LOG_AGE_DAYS = 7 +const TAIL_LINES = 1000 +const EXPORT_WINDOW = 24 * 60 * 60 * 1000 +const MAX_EXPORT_FILE_SIZE = 50 * 1024 * 1024 +const NET_LOG_SIZE = 20 * 1024 * 1024 + +let root = "" +let run = "" +let netLogPath: string | undefined + +let logger: MainLogger +export const getLogger = () => logger + +export function initLogging() { + initRunDirectory() + log.transports.file.maxSize = 5 * 1024 * 1024 + log.transports.file.resolvePathFn = (_vars, message) => + join( + run, + `${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`, + ) + log.initialize({ preload: false, spyRendererConsole: true }) + initConsoleTransport() + cleanup() + return (logger = log) +} + +export function initCrashReporter() { + const dir = join(app.getPath("userData"), "Crashpad") + mkdirSync(dir, { recursive: true }) + app.setPath("crashDumps", dir) + crashReporter.start({ uploadToServer: false, compress: true }) + write("crash", "crash reporter started", { path: dir }) +} + +export async function startNetLog() { + if (netLog.currentlyLogging) return + netLogPath = join(run, "network.netlog") + await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE }) + write("network", "net log started", { path: netLogPath }) +} + +export async function exportDebugLogs() { + const restartNetLog = netLog.currentlyLogging + if (restartNetLog) { + await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error })) + } + + const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`) + try { + write("main", "exporting debug logs", { output }) + await writeZip(output, [ + { name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) }, + ...collect(root, "desktop"), + ...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)), + ...collect(app.getPath("crashDumps"), "crashpad"), + ]) + shell.showItemInFolder(output) + return output + } finally { + if (restartNetLog) { + await startNetLog().catch((error) => write("network", "failed to restart net log", { error })) + } + } +} + +export function write( + name: string, + message: string, + extra?: Record, + level: "info" | "warn" | "error" = "info", +) { + if (!run) return + const scoped = log.scope(safeLogName(name)) + if (extra !== undefined) { + scoped[level](message, extra) + return + } + scoped[level](message) +} + +export function tail(): string { + try { + const path = log.transports.file.getFile().path + const contents = readFileSync(path, "utf8") + const lines = contents.split("\n") + return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n") + } catch { + return "" + } +} + +function initRunDirectory() { + root = join(app.getPath("userData"), "logs") + run = join(root, stamp()) + mkdirSync(run, { recursive: true }) +} + +function stamp() { + return new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d+Z$/, "") +} + +function safeLogName(name: string) { + return name.replace(/[^a-z0-9_.-]/gi, "_") || "main" +} + +function cleanup() { + const dir = root || dirname(log.transports.file.getFile().path) + const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000 + + for (const entry of readdirSync(dir)) { + const file = join(dir, entry) + try { + const info = statSync(file) + if (info.mtimeMs < cutoff) rmSync(file, { recursive: true, force: true }) + } catch { + continue + } + } +} + +function manifest() { + return { + generated: new Date().toISOString(), + version: app.getVersion(), + name: app.getName(), + packaged: app.isPackaged, + platform: process.platform, + arch: process.arch, + versions: process.versions, + uptime: process.uptime(), + userData: app.getPath("userData"), + logs: root, + currentRun: run, + crashDumps: app.getPath("crashDumps"), + serverLogs: serverLogRoots(), + netLog: netLogPath, + } +} + +function serverLogRoots() { + const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") + return [...new Set([join(xdgData, "opencode", "log"), join(app.getPath("userData"), "opencode", "log")])] +} + +type Entry = { name: string; path?: string; data?: Buffer } + +function collect(dir: string, prefix: string): Entry[] { + if (!existsSync(dir)) return [] + const cutoff = Date.now() - EXPORT_WINDOW + const result: Entry[] = [] + const walk = (current: string) => { + for (const entry of readdirSync(current)) { + const file = join(current, entry) + const info = statSync(file) + if (info.isDirectory()) { + walk(file) + continue + } + if (info.mtimeMs < cutoff) continue + if (info.size > MAX_EXPORT_FILE_SIZE) continue + if (file.endsWith(".heapsnapshot")) continue + result.push({ name: join(prefix, file.slice(dir.length + 1)).replace(/\\/g, "/"), path: file }) + } + } + walk(dir) + return result +} + +async function writeZip(output: string, entries: Entry[]) { + const writer = new ZipWriter(new BlobWriter("application/zip")) + for (const entry of entries) { + const data = entry.data ?? readFileSync(entry.path!) + await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)]))) + } + const zip = await writer.close() + writeFileSync(output, Buffer.from(await zip.arrayBuffer())) +} + +function initConsoleTransport() { + if (app.isPackaged) { + log.transports.console.level = false + return + } + + const write = log.transports.console.writeFn.bind(log.transports.console) + log.transports.console.writeFn = (options) => { + try { + write(options) + } catch (err) { + if (!isBrokenPipe(err)) throw err + log.transports.console.level = false + } + } +} + +function isBrokenPipe(err: unknown) { + return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE" +} diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac2da7e547c17b27dd3407337bad313f3d022b98 --- /dev/null +++ b/packages/desktop/src/main/menu.ts @@ -0,0 +1,69 @@ +import { BrowserWindow, Menu } from "electron" +import type { MenuItemConstructorOptions } from "electron" +import { + DESKTOP_MENU, + desktopMenuVisible, + type DesktopMenuEntry, + type DesktopMenuRole, +} from "@opencode-ai/app/desktop-menu" + +import { UPDATER_ENABLED } from "./constants" +import { runDesktopMenuAction } from "./desktop-menu-actions" +import { openExternalURL } from "./windows" +import { nativeT } from "./native-translations" + +type Deps = { + trigger: (id: string) => void + checkForUpdates: () => void + relaunch: () => void +} + +export function createMenu(deps: Deps) { + if (process.platform !== "darwin") return + + const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => { + if (menu.role) return { role: nativeRole(menu.role), label: nativeT(menu.labelKey) } + return { + label: nativeT(menu.labelKey), + submenu: menu.items + ?.filter((entry) => desktopMenuVisible(entry, "macos")) + .map((entry) => nativeItem(entry, deps)), + } + }) + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} + +function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions { + if (entry.type === "separator") return { type: "separator" } + if (entry.role) return { role: nativeRole(entry.role), label: entry.labelKey ? nativeT(entry.labelKey) : undefined } + + const item: MenuItemConstructorOptions = { + label: entry.labelKey ? nativeT(entry.labelKey) : undefined, + accelerator: entry.accelerator?.macos, + enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined, + } + + if (entry.command) { + const command = entry.command + item.click = () => deps.trigger(command) + } + if (entry.action) { + const action = entry.action + item.click = () => + runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, { + checkForUpdates: deps.checkForUpdates, + relaunch: deps.relaunch, + }) + } + if (entry.href) { + const href = entry.href + item.click = () => openExternalURL(href) + } + + return item +} + +function nativeRole(role: DesktopMenuRole) { + return role as NonNullable +} diff --git a/packages/desktop/src/main/migrate.ts b/packages/desktop/src/main/migrate.ts new file mode 100644 index 0000000000000000000000000000000000000000..70e3dc9c75036617ecb6dd13b017170a50f9d28c --- /dev/null +++ b/packages/desktop/src/main/migrate.ts @@ -0,0 +1,91 @@ +import { app } from "electron" +import log from "electron-log/main.js" +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { CHANNEL } from "./constants" +import { getStore } from "./store" + +const TAURI_MIGRATED_KEY = "tauriMigrated" + +// Resolve the directory where Tauri stored its .dat files for the given app identifier. +// Mirrors Tauri's AppLocalData / AppData resolution per OS. +function tauriDir(id: string) { + switch (process.platform) { + case "darwin": + return join(homedir(), "Library", "Application Support", id) + case "win32": + return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id) + default: + return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id) + } +} + +// The Tauri app identifier changes between dev/beta/prod builds. +const TAURI_APP_IDS: Record = { + dev: "ai.opencode.desktop.dev", + beta: "ai.opencode.desktop.beta", + prod: "ai.opencode.desktop", +} +function tauriAppId() { + return app.isPackaged ? TAURI_APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" +} + +// Migrate a single Tauri .dat file into the corresponding electron-store. +// `opencode.settings.dat` is special: it maps to the `opencode.settings` store +// (the electron-store name without the `.dat` extension). All other .dat files +// keep their full filename as the electron-store name so they match what the +// renderer already passes via IPC (e.g. `"default.dat"`, `"opencode.global.dat"`). +function migrateFile(datPath: string, filename: string) { + let data: Record + try { + data = JSON.parse(readFileSync(datPath, "utf-8")) + } catch (err) { + log.warn("tauri migration: failed to parse", filename, err) + return + } + + // opencode.settings.dat → the electron settings store ("opencode.settings"). + // All other .dat files keep their full filename as the store name so they match + // what the renderer passes via IPC (e.g. "default.dat", "opencode.global.dat"). + const storeName = filename === "opencode.settings.dat" ? "opencode.settings" : filename + const target = getStore(storeName) + const migrated: string[] = [] + const skipped: string[] = [] + + for (const [key, value] of Object.entries(data)) { + // Don't overwrite values the user has already set in the Electron app. + if (target.has(key)) { + skipped.push(key) + continue + } + target.set(key, value) + migrated.push(key) + } + + log.log("tauri migration: migrated", filename, "→", storeName, { migrated, skipped }) +} + +export function migrate() { + if (getStore().get(TAURI_MIGRATED_KEY)) { + log.log("tauri migration: already done, skipping") + return + } + + const dir = tauriDir(tauriAppId()) + log.log("tauri migration: starting", { dir }) + + if (!existsSync(dir)) { + log.log("tauri migration: no tauri data directory found, nothing to migrate") + getStore().set(TAURI_MIGRATED_KEY, true) + return + } + + for (const filename of readdirSync(dir)) { + if (!filename.endsWith(".dat")) continue + migrateFile(join(dir, filename), filename) + } + + log.log("tauri migration: complete") + getStore().set(TAURI_MIGRATED_KEY, true) +} diff --git a/packages/desktop/src/main/native-translations.ts b/packages/desktop/src/main/native-translations.ts new file mode 100644 index 0000000000000000000000000000000000000000..80e5caf412eb72cd59598d084fe00dbd50a00607 --- /dev/null +++ b/packages/desktop/src/main/native-translations.ts @@ -0,0 +1,24 @@ +import { + DESKTOP_NATIVE_ENGLISH, + DESKTOP_NATIVE_KEYS, + formatDesktopNativeMessage, + type DesktopNativeBundle, + type DesktopNativeKey, +} from "@opencode-ai/app/i18n/desktop-native" + +let bundle: DesktopNativeBundle = { locale: "en", messages: { ...DESKTOP_NATIVE_ENGLISH } } + +export function setNativeTranslations(next: DesktopNativeBundle) { + if ( + next.locale === bundle.locale && + DESKTOP_NATIVE_KEYS.every((key) => next.messages[key] === bundle.messages[key]) + ) { + return false + } + bundle = next + return true +} + +export function nativeT(key: DesktopNativeKey, params?: Record) { + return formatDesktopNativeMessage(bundle.messages[key], params) +} diff --git a/packages/desktop/src/main/onboarding.ts b/packages/desktop/src/main/onboarding.ts new file mode 100644 index 0000000000000000000000000000000000000000..2123551db4ffbe3e967b5b0b9dad2a04fc57b97f --- /dev/null +++ b/packages/desktop/src/main/onboarding.ts @@ -0,0 +1,45 @@ +import { existsSync, readdirSync } from "node:fs" +import { mkdir } from "node:fs/promises" +import { join } from "node:path" +import { app } from "electron" +import { getStore } from "./store" +import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, OLD_LAYOUT_ELIGIBLE_KEY } from "./store-keys" +import { write as writeLog } from "./logging" +import { hasExistingAppState } from "./install-state" + +const DEFAULT_PROJECT_DIR = "Default Project" + +export function initializeOldLayoutEligibility(userDataPath: string) { + const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : [] + const store = getStore() + const current = store.get(OLD_LAYOUT_ELIGIBLE_KEY) + if (typeof current === "boolean") return current + + const eligible = hasExistingAppState(entries) + store.set(OLD_LAYOUT_ELIGIBLE_KEY, eligible) + return eligible +} + +export function isOldLayoutEligible() { + return getStore().get(OLD_LAYOUT_ELIGIBLE_KEY) === true +} + +export function isFirstLaunchOnboardingPending() { + const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true + writeLog("onboarding", "first launch onboarding pending checked", { pending }) + return pending +} + +export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) { + if (!isFirstLaunchOnboardingPending()) { + writeLog("onboarding", "first launch onboarding already completed") + return null + } + + const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null + if (defaultProject) await mkdir(defaultProject, { recursive: true }) + + getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true) + writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject }) + return defaultProject +} diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae1a98efdfaf862cd1c72837e3e533b25fe6d3db --- /dev/null +++ b/packages/desktop/src/main/server.ts @@ -0,0 +1,239 @@ +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { app, utilityProcess } from "electron" +import type { Details } from "electron" +import { getLogger } from "./logging" +import { getUserShell, loadShellEnv } from "./shell-env" +import { getStore } from "./store" +import { DEFAULT_SERVER_URL_KEY } from "./store-keys" + +export type HealthCheck = { wait: Promise } + +type SidecarMessage = + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +export type SidecarListener = { stop: () => Promise } + +const SIDECAR_SERVICE_NAME = "opencode server" +const SIDECAR_START_STALL_TIMEOUT = 60_000 +const SIDECAR_STOP_TIMEOUT = 6_000 + +type SpawnLocalServerOptions = { + userDataPath: string + onStdout?: (message: string) => void + onStderr?: (message: string) => void + onExit?: (code: number) => void +} + +export function getDefaultServerUrl(): string | null { + const value = getStore().get(DEFAULT_SERVER_URL_KEY) + return typeof value === "string" ? value : null +} + +export function setDefaultServerUrl(url: string | null) { + if (url) { + getStore().set(DEFAULT_SERVER_URL_KEY, url) + return + } + + getStore().delete(DEFAULT_SERVER_URL_KEY) +} + +export function preferAppEnv(userDataPath: string) { + const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null + Object.assign(process.env, { + ...shellEnv, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) + return shellEnv +} + +export async function spawnLocalServer( + hostname: string, + port: number, + password: string, + options: SpawnLocalServerOptions, +) { + const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js") + const child = utilityProcess.fork(sidecar, [], { + cwd: process.cwd(), + env: createSidecarEnv(), + serviceName: SIDECAR_SERVICE_NAME, + stdio: "pipe", + }) + let exited = false + const exit = defer() + + const onProcessGone = (_event: unknown, details: Details) => { + if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return + options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`) + } + + app.on("child-process-gone", onProcessGone) + child.once("exit", (code) => { + exited = true + app.off("child-process-gone", onProcessGone) + options.onExit?.(code) + exit.resolve(code) + }) + child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`)) + + child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd())) + child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd())) + + await new Promise((resolve, reject) => { + let done = false + let timeout: NodeJS.Timeout + + const fail = (error: Error) => { + if (done) return + done = true + cleanup() + reject(error) + } + + const refreshTimeout = () => { + clearTimeout(timeout) + timeout = setTimeout(() => { + fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`)) + }, SIDECAR_START_STALL_TIMEOUT) + } + + const onMessage = (message: SidecarMessage) => { + if (message.type === "ready") { + if (done) return + done = true + cleanup() + resolve() + return + } + if (message.type === "error") { + fail(Object.assign(new Error(message.error.message), { stack: message.error.stack })) + } + } + const onExit = (code: number) => { + fail(new Error(`Sidecar exited before ready with code ${code}`)) + } + const cleanup = () => { + clearTimeout(timeout) + child.off("message", onMessage) + child.off("exit", onExit) + } + + child.on("message", onMessage) + child.on("exit", onExit) + refreshTimeout() + child.postMessage({ + type: "start", + hostname, + port, + password, + userDataPath: options.userDataPath, + }) + }).catch((error) => { + if (!exited) child.kill() + throw error + }) + + const wait = (async () => { + const url = `http://${hostname}:${port}` + let healthy = false + const gone = exit.promise.then((code) => { + if (healthy) return + throw new Error(`Sidecar exited before health check passed with code ${code}`) + }) + + const ready = async () => { + while (true) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if (await checkHealth(url, password)) { + healthy = true + return + } + } + } + + await Promise.race([ready(), gone]) + })() + + let stopping: Promise | undefined + + return { + listener: { + stop: () => { + if (stopping) return stopping + if (exited) return Promise.resolve() + child.postMessage({ type: "stop" }) + stopping = Promise.race([ + exit.promise.then(() => undefined), + delay(SIDECAR_STOP_TIMEOUT).then(() => { + if (!exited) child.kill() + }), + ]) + return stopping + }, + }, + health: { wait }, + } +} + +export async function checkHealth(url: string, password?: string | null): Promise { + let healthUrls: URL[] + try { + healthUrls = [new URL("/api/health", url), new URL("/global/health", url)] + } catch { + return false + } + + const headers = new Headers() + if (password) { + const auth = Buffer.from(`opencode:${password}`).toString("base64") + headers.set("authorization", `Basic ${auth}`) + } + + for (const healthUrl of healthUrls) { + try { + const res = await fetch(healthUrl, { + method: "GET", + headers, + signal: AbortSignal.timeout(3000), + }) + if (res.ok) return true + } catch {} + } + return false +} + +function createSidecarEnv(): Record { + const env = Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])), + ) + delete env.DEBUG + if (process.platform === "linux") delete env.LD_PRELOAD + return env +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function defer() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/packages/desktop/src/main/shell-env.test.ts b/packages/desktop/src/main/shell-env.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e71708ad0491506ef9a4c89461cb44372fd4b3dd --- /dev/null +++ b/packages/desktop/src/main/shell-env.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" + +import { isNushell, mergeShellEnv, parseShellEnv, resolveUserShell } from "./shell-env" + +describe("shell env", () => { + test("parseShellEnv supports null-delimited pairs", () => { + const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0")) + + expect(env.PATH).toBe("/usr/bin:/bin") + expect(env.FOO).toBe("bar=baz") + }) + + test("parseShellEnv ignores invalid entries", () => { + const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0")) + + expect(Object.keys(env).length).toBe(1) + expect(env.OK).toBe("1") + }) + + test("mergeShellEnv keeps explicit overrides", () => { + const env = mergeShellEnv( + { + PATH: "/shell/path", + HOME: "/tmp/home", + }, + { + PATH: "/desktop/path", + OPENCODE_CLIENT: "desktop", + }, + ) + + expect(env.PATH).toBe("/desktop/path") + expect(env.HOME).toBe("/tmp/home") + expect(env.OPENCODE_CLIENT).toBe("desktop") + }) + + test("resolveUserShell falls back to the login shell before /bin/sh", () => { + expect(resolveUserShell("/custom/env-shell", "/bin/zsh")).toBe("/custom/env-shell") + expect(resolveUserShell(undefined, "/bin/zsh")).toBe("/bin/zsh") + expect(resolveUserShell(undefined, "unknown")).toBe("/bin/sh") + expect(resolveUserShell(undefined, undefined)).toBe("/bin/sh") + }) + + test("isNushell handles path and binary name", () => { + expect(isNushell("nu")).toBe(true) + expect(isNushell("/opt/homebrew/bin/nu")).toBe(true) + expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true) + expect(isNushell("/bin/zsh")).toBe(false) + }) +}) diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts new file mode 100644 index 0000000000000000000000000000000000000000..082ed5e930dbde3901fb99fcf0e7811bc8470150 --- /dev/null +++ b/packages/desktop/src/main/shell-env.ts @@ -0,0 +1,101 @@ +import { spawnSync } from "node:child_process" +import { userInfo } from "node:os" +import { basename } from "node:path" + +const TIMEOUT = 5_000 + +type Probe = { type: "Loaded"; value: Record } | { type: "Timeout" } | { type: "Unavailable" } +type ShellEnvLogger = { + log: (message: string) => void +} + +export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) { + const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined + return envShell || resolvedLoginShell || "/bin/sh" +} + +export function getUserShell() { + try { + return resolveUserShell(process.env.SHELL, userInfo().shell) + } catch { + return resolveUserShell(process.env.SHELL, undefined) + } +} + +export function parseShellEnv(out: Buffer) { + const env: Record = {} + for (const line of out.toString("utf8").split("\0")) { + if (!line) continue + const ix = line.indexOf("=") + if (ix <= 0) continue + env[line.slice(0, ix)] = line.slice(ix + 1) + } + return env +} + +function probe(shell: string, mode: "-il" | "-l"): Probe { + const out = spawnSync(shell, [mode, "-c", "env -0"], { + stdio: ["ignore", "pipe", "ignore"], + timeout: TIMEOUT, + windowsHide: true, + }) + + const err = out.error as NodeJS.ErrnoException | undefined + if (err) { + if (err.code === "ETIMEDOUT") return { type: "Timeout" } + console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`) + return { type: "Unavailable" } + } + + if (out.status !== 0) { + console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`) + return { type: "Unavailable" } + } + + const env = parseShellEnv(out.stdout) + if (Object.keys(env).length === 0) { + console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`) + return { type: "Unavailable" } + } + + return { type: "Loaded", value: env } +} + +export function isNushell(shell: string) { + const name = basename(shell).toLowerCase() + const raw = shell.toLowerCase() + return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe") +} + +export function loadShellEnv(shell: string, logger: ShellEnvLogger) { + if (isNushell(shell)) { + logger.log(`[server] Skipping shell env probe for nushell: ${shell}`) + return null + } + + const interactive = probe(shell, "-il") + if (interactive.type === "Loaded") { + logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + return interactive.value + } + if (interactive.type === "Timeout") { + logger.log(`[server] Interactive shell env probe timed out: ${shell}`) + return null + } + + const login = probe(shell, "-l") + if (login.type === "Loaded") { + logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + return login.value + } + + logger.log(`[server] Falling back to app environment: ${shell}`) + return null +} + +export function mergeShellEnv(shell: Record | null, env: Record) { + return { + ...shell, + ...env, + } +} diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts new file mode 100644 index 0000000000000000000000000000000000000000..246871fb2b4c4b5422f03fa52e5964e41f2231b2 --- /dev/null +++ b/packages/desktop/src/main/sidecar.ts @@ -0,0 +1,157 @@ +import * as http from "node:http" +import * as tls from "node:tls" + +type NodeHttpWithEnvProxy = typeof http & { + setGlobalProxyFromEnv: () => void +} + +type NodeTlsWithSystemCertificates = typeof tls & { + getCACertificates: (type: "default" | "system") => string[] + setDefaultCACertificates: (certificates: string[]) => void +} + +type StartCommand = { + type: "start" + hostname: string + port: number + password: string + userDataPath: string +} + +type StopCommand = { type: "stop" } +type SidecarCommand = StartCommand | StopCommand + +type SidecarMessage = + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +type ParentPort = { + postMessage(message: SidecarMessage): void + on(event: "message", listener: (event: { data: unknown }) => void): void +} + +type Listener = { + stop(close?: boolean): void | Promise +} + +const parentPort = getParentPort() +let listener: Listener | undefined + +parentPort.on("message", (event) => { + const command = parseCommand(event.data) + if (!command) return + if (command.type === "stop") { + void stop() + return + } + void start(command) +}) + +async function start(command: StartCommand) { + try { + prepareSidecarEnv(command.password, command.userDataPath) + ensureLoopbackNoProxy() + useSystemCertificates() + useEnvProxy() + const { Server } = await import("virtual:opencode-server") + + listener = await Server.listen({ + port: command.port, + hostname: command.hostname, + username: "opencode", + password: command.password, + cors: ["oc://renderer"], + }) + parentPort.postMessage({ type: "ready" }) + } catch (error) { + parentPort.postMessage({ type: "error", error: serializeError(error) }) + setImmediate(() => process.exit(1)) + } +} + +async function stop() { + try { + await listener?.stop() + } finally { + listener = undefined + parentPort.postMessage({ type: "stopped" }) + setImmediate(() => process.exit(0)) + } +} + +function prepareSidecarEnv(password: string, userDataPath: string) { + Object.assign(process.env, { + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_SERVER_PASSWORD: password, + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) +} + +function ensureLoopbackNoProxy() { + const loopback = ["127.0.0.1", "localhost", "::1"] + const upsert = (key: string) => { + const items = (process.env[key] ?? "") + .split(",") + .map((value: string) => value.trim()) + .filter((value: string) => Boolean(value)) + + for (const host of loopback) { + if (items.some((value: string) => value.toLowerCase() === host)) continue + items.push(host) + } + + process.env[key] = items.join(",") + } + + upsert("NO_PROXY") + upsert("no_proxy") +} + +function useSystemCertificates() { + try { + const nodeTls = tls as NodeTlsWithSystemCertificates + nodeTls.setDefaultCACertificates([ + ...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]), + ]) + } catch (error) { + console.warn("failed to load system certificates", error) + } +} + +function useEnvProxy() { + try { + ;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv() + } catch (error) { + console.warn("failed to load proxy environment", error) + } +} + +function parseCommand(value: unknown): SidecarCommand | undefined { + if (!value || typeof value !== "object") return + const command = value as Partial + if (command.type === "stop") return { type: "stop" } + if (command.type !== "start") return + if (typeof command.hostname !== "string") return + if (typeof command.port !== "number") return + if (typeof command.password !== "string") return + if (typeof command.userDataPath !== "string") return + return { + type: "start", + hostname: command.hostname, + port: command.port, + password: command.password, + userDataPath: command.userDataPath, + } +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function getParentPort() { + const port = process.parentPort as ParentPort | undefined + if (!port) throw new Error("Sidecar parent port unavailable") + return port +} diff --git a/packages/desktop/src/main/store-cleanup.test.ts b/packages/desktop/src/main/store-cleanup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c63dd0b0f5596c37f93f63fe89a34087815ae91b --- /dev/null +++ b/packages/desktop/src/main/store-cleanup.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./store-cleanup" + +const roots: string[] = [] + +async function tempRoot() { + const root = await mkdtemp(join(tmpdir(), "opencode-store-cleanup-")) + roots.push(root) + return root +} + +async function writeStore(root: string, name: string, value: string, modified: Date) { + await writeFile(join(root, name), value) + await utimes(join(root, name), modified, modified) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe("store cleanup", () => { + test("removes empty scoped stores and leaves global stores alone", async () => { + const root = await tempRoot() + const now = new Date("2026-07-01T00:00:00.000Z") + await writeStore(root, "opencode.draft.empty.dat", "{}", now) + await writeStore(root, "opencode.workspace.empty.dat", "{\n}", now) + await writeStore(root, "opencode.global.dat", "{}", now) + await writeStore(root, "opencode.workspace.empty.dat.json", "{}", now) + + const result = await cleanupStoreFiles(root, now.getTime()) + + expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"]) + expect((await readdir(root)).sort()).toEqual(["opencode.global.dat", "opencode.workspace.empty.dat.json"]) + }) + + test("removes stale drafts by age without removing non-empty workspace stores", async () => { + const root = await tempRoot() + const now = new Date("2026-07-01T00:00:00.000Z") + await writeStore(root, "opencode.draft.old.dat", '{"draft:prompt":"hello"}', new Date("2026-05-01T00:00:00.000Z")) + await writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now) + await writeStore( + root, + "opencode.workspace.old.dat", + '{"workspace:layout":"wide"}', + new Date("2025-01-01T00:00:00.000Z"), + ) + await writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now) + + const result = await cleanupStoreFiles(root, now.getTime()) + + expect(result.deleted).toEqual(["opencode.draft.old.dat"]) + expect((await readdir(root)).sort()).toEqual([ + "opencode.draft.recent.dat", + "opencode.workspace.old.dat", + "opencode.workspace.recent.dat", + ]) + }) + + test("caps scoped stores by recency", async () => { + const root = await tempRoot() + const now = new Date("2026-07-01T00:00:00.000Z") + await Promise.all( + Array.from({ length: 102 }, (_, index) => + writeStore( + root, + `opencode.draft.${index}.dat`, + '{"draft:prompt":"hello"}', + new Date(now.getTime() - index * 1000), + ), + ), + ) + + const result = await cleanupStoreFiles(root, now.getTime()) + + const remaining = await readdir(root) + + expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"]) + expect(remaining).toHaveLength(100) + }) + + test("removes a scoped store immediately when it becomes empty", async () => { + const root = await tempRoot() + await writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z")) + await writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z")) + + expect(await deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true) + expect(await deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false) + expect(await readdir(root)).toEqual(["opencode.global.dat"]) + }) +}) diff --git a/packages/desktop/src/main/store-cleanup.ts b/packages/desktop/src/main/store-cleanup.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d592ebc4b6e4cc05a1778eaa0feac3e59e9bf47 --- /dev/null +++ b/packages/desktop/src/main/store-cleanup.ts @@ -0,0 +1,94 @@ +import { readdir, readFile, rm, stat } from "node:fs/promises" +import { join } from "node:path" + +const EMPTY_STORE_MAX_BYTES = 128 +const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 +const DRAFT_KEEP_RECENT = 100 + +type StoreKind = "draft" | "workspace" +type StoreCandidate = { + name: string + path: string + kind: StoreKind + modified: number + empty: boolean +} + +export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) { + const entries = await readdir(userDataPath, { withFileTypes: true }).catch(() => []) + const candidates = ( + await Promise.all( + entries + .filter((entry) => entry.isFile()) + .map(async (entry) => { + const kind = storeKind(entry.name) + if (!kind) return + + const file = join(userDataPath, entry.name) + const stats = await stat(file).catch(() => undefined) + if (!stats?.isFile()) return + + return { + name: entry.name, + path: file, + kind, + modified: stats.mtimeMs, + empty: await isEmptyStore(file, stats.size), + } + }), + ) + ).filter((candidate) => !!candidate) + + const stale = new Set() + for (const candidate of candidates) { + if (candidate.empty) stale.add(candidate) + if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate) + } + + candidates + .filter((candidate) => candidate.kind === "draft" && !candidate.empty) + .sort((a, b) => b.modified - a.modified) + .slice(DRAFT_KEEP_RECENT) + .forEach((candidate) => stale.add(candidate)) + + const deleted = await Promise.all( + [...stale].map(async (candidate) => { + await rm(candidate.path, { force: true }) + return candidate.name + }), + ) + + return { scanned: candidates.length, deleted } +} + +export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) { + if (!storeKind(name)) return false + + const file = join(userDataPath, name) + const stats = await stat(file).catch(() => undefined) + if (!stats?.isFile()) return false + if (!(await isEmptyStore(file, stats.size))) return false + + await rm(file, { force: true }) + return true +} + +function storeKind(name: string): StoreKind | undefined { + if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft" + if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace" +} + +async function isEmptyStore(file: string, size: number) { + if (size > EMPTY_STORE_MAX_BYTES) return false + + const raw = await readFile(file, "utf8").catch(() => undefined) + if (raw === undefined) return false + if (raw.trim() === "") return true + + try { + const parsed = JSON.parse(raw) as unknown + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0 + } catch { + return false + } +} diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6f4030dba51534688a42597b3ea767b70cbf564 --- /dev/null +++ b/packages/desktop/src/main/store-keys.ts @@ -0,0 +1,7 @@ +export const SETTINGS_STORE = "opencode.settings" +export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" +export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete" +export const OLD_LAYOUT_ELIGIBLE_KEY = "oldLayoutEligible" +export const WSL_SERVERS_KEY = "wslServers" +export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" +export const WINDOW_IDS_KEY = "windowIds" diff --git a/packages/desktop/src/main/store.ts b/packages/desktop/src/main/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..687429ed43658c6306b3ac35d388bcf865eb69c6 --- /dev/null +++ b/packages/desktop/src/main/store.ts @@ -0,0 +1,35 @@ +import Store from "electron-store" +import electron from "electron" +import { rmSync } from "node:fs" +import { join } from "node:path" + +import { SETTINGS_STORE } from "./store-keys" +import { deleteStoreFileIfEmpty } from "./store-cleanup" + +const cache = new Map() + +// We cannot instantiate the electron-store at module load time because +// module import hoisting causes this to run before app.setPath("userData", ...) +// in index.ts has executed, which would result in files being written to the default directory +// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings). +export function getStore(name = SETTINGS_STORE) { + const cached = cache.get(name) + if (cached) return cached + const next = new Store({ + name, + cwd: electron.app.getPath("userData"), + fileExtension: "", + accessPropertiesByDotNotation: false, + }) + cache.set(name, next) + return next +} + +export async function removeStoreFileIfEmpty(name: string) { + if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name) +} + +export function removeStoreFile(name: string) { + rmSync(join(electron.app.getPath("userData"), name), { force: true }) + cache.delete(name) +} diff --git a/packages/desktop/src/main/unresponsive.ts b/packages/desktop/src/main/unresponsive.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5778d9e912eb39d552f7214557641b9cacd934a --- /dev/null +++ b/packages/desktop/src/main/unresponsive.ts @@ -0,0 +1,70 @@ +import type { BrowserWindow } from "electron" +import { write as writeLog } from "./logging" +import { safeWindowURL } from "./window-state" + +const sampleInterval = 1000 +const samplePeriod = 15000 + +export function createUnresponsiveSampler(win: BrowserWindow, name: string) { + let sampleTimer: ReturnType | undefined + let stopTimer: ReturnType | undefined + let sampling = false + const samples = new Map() + + const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed() + const clearTimers = () => { + if (sampleTimer) clearTimeout(sampleTimer) + if (stopTimer) clearTimeout(stopTimer) + sampleTimer = undefined + stopTimer = undefined + } + + const schedule = () => { + sampleTimer = setTimeout(() => { + void collect() + }, sampleInterval) + } + + const collect = async () => { + if (!active()) return + const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => { + writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error") + return undefined + }) + if (!active()) return + if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1) + schedule() + } + + const stopAndFlush = () => { + const wasSampling = sampling + sampling = false + clearTimers() + if (samples.size === 0) return wasSampling + + const entries = [...samples.entries()].sort((a, b) => b[1] - a[1]) + const total = entries.reduce((sum, entry) => sum + entry[1], 0) + const message = [ + "renderer unresponsive samples", + `Window: ${name}`, + `URL: ${safeWindowURL(win)}`, + ...entries.map((entry) => `<${entry[1]}> ${entry[0]}`), + `Total Samples: ${total}`, + ].join("\n") + writeLog("window", message, undefined, "error") + samples.clear() + return wasSampling + } + + const start = () => { + if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return + sampling = true + samples.clear() + schedule() + stopTimer = setTimeout(stopAndFlush, samplePeriod) + } + + win.on("closed", stopAndFlush) + + return { start, stopAndFlush } +} diff --git a/packages/desktop/src/main/updater-controller.test.ts b/packages/desktop/src/main/updater-controller.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff3f7d1dfd83efd868f71e91ed856da6545ad43b --- /dev/null +++ b/packages/desktop/src/main/updater-controller.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test" +import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller" + +function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) { + const calls: string[] = [] + const backend: UpdaterBackend = { + async checkForUpdates() { + calls.push("check") + return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } } + }, + async downloadUpdate() { + calls.push("download") + }, + quitAndInstall() { + calls.push("install") + }, + } + let ready = input?.ready + const controller = createUpdaterController({ + enabled: true, + currentVersion: input?.currentVersion ?? "1.0.0", + backend, + persistence: { + get: () => ready, + set: (value) => { + ready = value + }, + clear: () => { + ready = undefined + }, + }, + stop: async () => { + calls.push("stop") + }, + }) + return { controller, calls, getReady: () => ready } +} + +describe("updater controller", () => { + test("checks, downloads, persists, and publishes one authoritative ready state", async () => { + const app = setup() + const states: ReturnType[] = [] + app.controller.subscribe((state) => states.push(state)) + + await app.controller.start() + + expect(app.calls).toEqual(["check", "download"]) + expect(app.getReady()).toEqual({ version: "2.0.0" }) + expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"]) + expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" }) + }) + + test("revalidates a persisted target through the updater cache on launch", async () => { + const app = setup({ ready: { version: "2.0.0" } }) + + await app.controller.start() + + expect(app.calls).toEqual(["check", "download"]) + expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" }) + }) + + test("clears a target already installed before checking", async () => { + const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } }) + + await app.controller.start() + + expect(app.getReady()).toBeUndefined() + expect(app.calls).toEqual(["check"]) + }) + + test("coalesces concurrent checks", async () => { + const app = setup() + + await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()]) + + expect(app.calls).toEqual(["check", "download"]) + }) + + test("returns to ready when quitAndInstall returns without exiting", async () => { + const app = setup() + await app.controller.start() + + await app.controller.install() + + expect(app.calls).toEqual(["check", "download", "stop", "install"]) + expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" }) + }) + + test("returns to ready when installation cannot start", async () => { + const app = setup() + await app.controller.start() + + const failed = createUpdaterController({ + enabled: true, + currentVersion: "1.0.0", + backend: { + checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }), + downloadUpdate: async () => {}, + quitAndInstall() {}, + }, + persistence: { get: () => undefined, set() {}, clear() {} }, + stop: async () => { + throw new Error("stop failed") + }, + }) + await failed.start() + + await expect(failed.install()).rejects.toThrow("stop failed") + expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" }) + }) +}) diff --git a/packages/desktop/src/main/updater-controller.ts b/packages/desktop/src/main/updater-controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0ae1a8927456a15f4e6abf1572b0d7c10627646 --- /dev/null +++ b/packages/desktop/src/main/updater-controller.ts @@ -0,0 +1,97 @@ +import type { UpdaterState } from "@opencode-ai/app/updater" + +export type { UpdaterState } from "@opencode-ai/app/updater" + +export type UpdaterReadyRecord = { version: string } + +export type UpdaterBackend = { + checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined> + downloadUpdate(): Promise + quitAndInstall(): void +} + +type UpdaterPersistence = { + get(): UpdaterReadyRecord | undefined | Promise + set(value: UpdaterReadyRecord): void | Promise + clear(): void | Promise +} + +export function createUpdaterController(input: { + enabled: boolean + currentVersion: string + backend: UpdaterBackend + persistence: UpdaterPersistence + stop: () => Promise + log?: (message: string, data?: object) => void +}) { + let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" } + let pending: Promise | undefined + const listeners = new Set<(state: UpdaterState) => void>() + + const transition = (next: UpdaterState) => { + input.log?.("updater state changed", { from: state.status, to: next.status }) + state = next + listeners.forEach((listener) => listener(state)) + return state + } + + const check = () => { + if (!input.enabled) return Promise.resolve(state) + if (state.status === "ready") return Promise.resolve(state) + if (pending) return pending + + pending = (async () => { + transition({ status: "checking" }) + const result = await input.backend.checkForUpdates() + const version = result?.updateInfo?.version + if (!result?.isUpdateAvailable || !version || version === input.currentVersion) { + await input.persistence.clear() + return transition({ status: "up-to-date" }) + } + + transition({ status: "downloading", version }) + await input.backend.downloadUpdate() + await input.persistence.set({ version }) + return transition({ status: "ready", version }) + })() + .catch((error) => + transition({ status: "error", message: error instanceof Error ? error.message : String(error) }), + ) + .finally(() => { + pending = undefined + }) + return pending + } + + return { + getState: () => state, + subscribe(listener: (state: UpdaterState) => void) { + listeners.add(listener) + listener(state) + return () => listeners.delete(listener) + }, + async start() { + const ready = await input.persistence.get() + if (ready?.version === input.currentVersion) await input.persistence.clear() + return check() + }, + check, + async install() { + if (state.status !== "ready") throw new Error("Update is not ready to install") + const version = state.version + transition({ status: "installing", version }) + await input + .stop() + .then(() => { + input.backend.quitAndInstall() + transition({ status: "ready", version }) + }) + .catch((error) => { + transition({ status: "ready", version }) + throw error + }) + }, + } +} + +export type UpdaterController = ReturnType diff --git a/packages/desktop/src/main/updater-subscriptions.test.ts b/packages/desktop/src/main/updater-subscriptions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..09936a25b7d5a331db0e51e0fedf2dc6f7081b59 --- /dev/null +++ b/packages/desktop/src/main/updater-subscriptions.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { createUpdaterSubscriptions } from "./updater-subscriptions" + +describe("updater subscriptions", () => { + test("replaces the previous renderer subscription on reload", () => { + const subscriptions = createUpdaterSubscriptions() + const disposed: string[] = [] + + subscriptions.set(1, () => disposed.push("first")) + subscriptions.set(1, () => disposed.push("second")) + + expect(disposed).toEqual(["first"]) + subscriptions.delete(1) + expect(disposed).toEqual(["first", "second"]) + }) +}) diff --git a/packages/desktop/src/main/updater-subscriptions.ts b/packages/desktop/src/main/updater-subscriptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..68f53d5377b5b1fab95a86bd0942b60e001e4551 --- /dev/null +++ b/packages/desktop/src/main/updater-subscriptions.ts @@ -0,0 +1,20 @@ +export function createUpdaterSubscriptions() { + const subscriptions = new Map void>() + + const remove = (id: number) => { + subscriptions.get(id)?.() + subscriptions.delete(id) + } + + return { + set(id: number, unsubscribe: () => void) { + remove(id) + subscriptions.set(id, unsubscribe) + }, + delete: remove, + clear() { + subscriptions.forEach((unsubscribe) => unsubscribe()) + subscriptions.clear() + }, + } +} diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts new file mode 100644 index 0000000000000000000000000000000000000000..08350917c1b6b5e5b53618bf5e8d7ac471ecfeaa --- /dev/null +++ b/packages/desktop/src/main/updater.ts @@ -0,0 +1,94 @@ +import { app, dialog } from "electron" +import pkg from "electron-updater" +import { UPDATER_ENABLED } from "./constants" +import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller" +import { getLogger } from "./logging" +import { getStore } from "./store" +import { setAppQuitting } from "./windows" +import { nativeT } from "./native-translations" + +const { autoUpdater } = pkg +const key = "ready" + +export function setupAutoUpdater(stop: () => Promise) { + const logger = getLogger() + autoUpdater.logger = logger + autoUpdater.channel = "latest" + autoUpdater.allowPrerelease = false + autoUpdater.allowDowngrade = true + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + logger.log("auto updater configured", { + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + currentVersion: app.getVersion(), + }) + + const store = getStore("opencode.updater") + return createUpdaterController({ + enabled: UPDATER_ENABLED, + currentVersion: app.getVersion(), + backend: { + checkForUpdates: () => autoUpdater.checkForUpdates(), + downloadUpdate: () => autoUpdater.downloadUpdate(), + quitAndInstall: () => { + // quitAndInstall closes all windows before emitting before-quit, so + // flag the quit first to keep window ids persisted for restore. + setAppQuitting() + try { + autoUpdater.quitAndInstall() + } catch (error) { + // The install failed and the app keeps running; clear the flag so + // deliberate window closes prune ids again. + setAppQuitting(false) + throw error + } + }, + }, + persistence: { + get() { + const value = store.get(key) + if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return + return { version: value.version } satisfies UpdaterReadyRecord + }, + set: (value) => store.set(key, value), + clear: () => store.delete(key), + }, + stop, + log: (message, data) => logger.log(message, data), + }) +} + +export async function showUpdaterDialog(controller: ReturnType, alertOnFail: boolean) { + const state = await controller.check() + if (state.status === "error") { + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "error", + message: nativeT("desktop.updater.dialog.checkFailed.message"), + title: nativeT("desktop.updater.dialog.checkFailed.title"), + }) + return + } + if (state.status === "up-to-date") { + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "info", + message: nativeT("desktop.updater.dialog.upToDate.message"), + title: nativeT("desktop.updater.dialog.upToDate.title"), + }) + return + } + if (state.status !== "ready") return + + const response = await dialog.showMessageBox({ + type: "info", + message: nativeT("desktop.updater.dialog.ready.message", { version: state.version }), + title: nativeT("desktop.updater.dialog.ready.title"), + buttons: [nativeT("desktop.updater.dialog.restart"), nativeT("desktop.updater.dialog.later")], + defaultId: 0, + cancelId: 1, + }) + if (response.response === 0) await controller.install() +} diff --git a/packages/desktop/src/main/window-registry.test.ts b/packages/desktop/src/main/window-registry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..756f597ebb001189f1c06f583ede94586a954b28 --- /dev/null +++ b/packages/desktop/src/main/window-registry.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test" +import { createWindowRegistry } from "./window-registry" + +function setup(initial: unknown = []) { + const state = { stored: initial } + const cleaned: string[] = [] + const registry = createWindowRegistry<{ name: string }>({ + read: () => state.stored, + write: (ids) => { + state.stored = ids + }, + cleanup: (id) => cleaned.push(id), + }) + return { registry, state, cleaned } +} + +describe("window registry", () => { + test("restores persisted ids and ignores malformed entries", () => { + expect(setup(["a", "", 42, "b"]).registry.persisted()).toEqual(["a", "b"]) + expect(setup("junk").registry.persisted()).toEqual([]) + expect(setup(undefined).registry.persisted()).toEqual([]) + }) + + test("registers windows and persists each id once", () => { + const app = setup() + app.registry.register("a", { name: "a" }) + app.registry.register("a", { name: "a" }) + app.registry.register("b", { name: "b" }) + expect(app.state.stored).toEqual(["a", "b"]) + }) + + test("forgets a deliberately closed window while others remain open", () => { + const app = setup() + app.registry.register("a", { name: "a" }) + app.registry.register("b", { name: "b" }) + app.registry.closed("a") + expect(app.state.stored).toEqual(["b"]) + expect(app.cleaned).toEqual(["a"]) + }) + + test("keeps the id when the last window closes so relaunch restores it", () => { + const app = setup() + app.registry.register("a", { name: "a" }) + app.registry.closed("a") + expect(app.state.stored).toEqual(["a"]) + expect(app.cleaned).toEqual([]) + + const restarted = createWindowRegistry<{ name: string }>({ + read: () => app.state.stored, + write: (ids) => { + app.state.stored = ids + }, + cleanup: () => {}, + }) + expect(restarted.persisted()).toEqual(["a"]) + }) + + test("keeps every id when windows close during quit", () => { + const app = setup() + app.registry.register("a", { name: "a" }) + app.registry.register("b", { name: "b" }) + app.registry.setQuitting() + app.registry.closed("a") + app.registry.closed("b") + expect(app.state.stored).toEqual(["a", "b"]) + expect(app.cleaned).toEqual([]) + }) + + test("tracks the last focused window and falls back on close", () => { + const app = setup() + app.registry.register("a", { name: "a" }) + app.registry.register("b", { name: "b" }) + app.registry.focused("a") + expect(app.registry.lastFocused()).toEqual({ name: "a" }) + app.registry.closed("a") + expect(app.registry.lastFocused()).toEqual({ name: "b" }) + app.registry.closed("b") + expect(app.registry.lastFocused()).toBeUndefined() + }) + + test("resumes forgetting closed windows after the quit flag resets", () => { + const app = setup() + app.registry.register("a", { name: "a" }) + app.registry.register("b", { name: "b" }) + app.registry.setQuitting() + app.registry.setQuitting(false) + app.registry.closed("a") + expect(app.state.stored).toEqual(["b"]) + expect(app.cleaned).toEqual(["a"]) + }) +}) diff --git a/packages/desktop/src/main/window-registry.ts b/packages/desktop/src/main/window-registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..f40902ef78de3239a9b81f8b455f16eca452ab47 --- /dev/null +++ b/packages/desktop/src/main/window-registry.ts @@ -0,0 +1,47 @@ +// Tracks open windows and the persisted window id list used to restore +// windows (and their per-window persisted state) across app launches. +export function createWindowRegistry(persistence: { + read: () => unknown + write: (ids: string[]) => void + cleanup: (id: string) => void +}) { + const windows = new Map() + let quitting = false + let lastFocusedID: string | undefined + + const persisted = () => { + const value = persistence.read() + if (!Array.isArray(value)) return [] + return value.filter((id): id is string => typeof id === "string" && id.length > 0) + } + + return { + persisted, + setQuitting(value = true) { + quitting = value + }, + register(id: string, window: W) { + windows.set(id, window) + const ids = persisted() + if (!ids.includes(id)) persistence.write([...ids, id]) + }, + focused(id: string) { + lastFocusedID = id + }, + lastFocused() { + if (!lastFocusedID) return + return windows.get(lastFocusedID) + }, + closed(id: string) { + windows.delete(id) + if (lastFocusedID === id) lastFocusedID = windows.keys().next().value + // Only a deliberate close (app keeps running with other windows open) + // forgets a window. Closing the last window quits the app and fires + // `closed` before `before-quit`, so treat it as a quit and keep the id + // for restore on next launch. + if (quitting || windows.size === 0) return + persistence.write(persisted().filter((item) => item !== id)) + persistence.cleanup(id) + }, + } +} diff --git a/packages/desktop/src/main/window-state.ts b/packages/desktop/src/main/window-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..3026b96b42a1b8495f8f0c84ed79b205368d6e8d --- /dev/null +++ b/packages/desktop/src/main/window-state.ts @@ -0,0 +1,29 @@ +export const destroyedWindowURL = "" + +type WebContentsURLState = { + isDestroyed(): boolean + getURL(): string +} + +type WindowURLState = { + isDestroyed(): boolean + readonly webContents: WebContentsURLState +} + +export function safeWebContentsURL(webContents: WebContentsURLState) { + try { + if (webContents.isDestroyed()) return destroyedWindowURL + return webContents.getURL() + } catch { + return destroyedWindowURL + } +} + +export function safeWindowURL(win: WindowURLState) { + try { + if (win.isDestroyed()) return destroyedWindowURL + return safeWebContentsURL(win.webContents) + } catch { + return destroyedWindowURL + } +} diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e7b8f3ff9b559dd92a7120e0a0eae07b88a99a8 --- /dev/null +++ b/packages/desktop/src/main/windows.ts @@ -0,0 +1,564 @@ +import windowState from "electron-window-state" +import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" +import type { DesktopTheme } from "@opencode-ai/ui/theme/types" +import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json" +import { randomUUID } from "node:crypto" +import { rmSync } from "node:fs" +import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol, shell } from "electron" +import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import type { TitlebarTheme } from "../preload/types" +import { exportDebugLogs, write as writeLog } from "./logging" +import { getStore, removeStoreFile } from "./store" +import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys" +import { createUnresponsiveSampler } from "./unresponsive" +import { nativeT } from "./native-translations" +import { createWindowRegistry } from "./window-registry" +import { safeWindowURL } from "./window-state" +import { resolveExternalURL, resolveLocalFilePath } from "./external-url" + +const root = dirname(fileURLToPath(import.meta.url)) +const rendererRoot = join(root, "../renderer") +const rendererProtocol = "oc" +const rendererHost = "renderer" +const clipboardWritePermission = "clipboard-sanitized-write" +const notificationPermission = "notifications" +const rendererPermissions = new Set([clipboardWritePermission, notificationPermission]) +const oc2Theme = oc2ThemeJson as DesktopTheme +const oc2Background = { + light: resolveThemeVariant(oc2Theme.light, false)["background-base"], + dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"], +} +const documentPolicyHeader = "Document-Policy" +const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports" + +protocol.registerSchemesAsPrivileged([ + { + scheme: rendererProtocol, + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + stream: true, + }, + }, +]) + +let backgroundColor: string | undefined +let relaunchHandler = () => { + setAppQuitting() + app.relaunch() + app.exit(0) +} +const titlebarThemes = new WeakMap>() +const pinchZoomEnabled = new WeakMap() +const windowIDs = new WeakMap() +const registry = createWindowRegistry({ + read: () => getStore().get(WINDOW_IDS_KEY), + write: (ids) => getStore().set(WINDOW_IDS_KEY, ids), + cleanup: (id) => { + rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true }) + removeStoreFile(windowDataFile(id)) + }, +}) +const titlebarHeight = 40 +const maxZoomLevel = 10 +const minZoomLevel = 0.2 + +export function setRelaunchHandler(handler: () => void) { + relaunchHandler = handler +} + +export function setAppQuitting(quitting = true) { + registry.setQuitting(quitting) +} + +export function setBackgroundColor(color: string) { + backgroundColor = color + BrowserWindow.getAllWindows().forEach((win) => { + win.setBackgroundColor(color) + if (process.platform === "darwin") win.invalidateShadow() + }) +} + +export function getBackgroundColor(): string | undefined { + return backgroundColor +} + +function iconsDir() { + return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons") +} + +function iconPath() { + const ext = process.platform === "win32" ? "ico" : "png" + return join(iconsDir(), `icon.${ext}`) +} + +function tone() { + return nativeTheme.shouldUseDarkColors ? "dark" : "light" +} + +function defaultBackgroundColor() { + return oc2Background[tone()] +} + +function overlay(theme: Partial = {}, zoom = 1) { + const mode = theme.mode ?? tone() + return { + color: "#00000000", + symbolColor: mode === "dark" ? "white" : "black", + height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)), + } +} + +export function setTitlebar(win: BrowserWindow, theme: Partial = {}) { + titlebarThemes.set(win, theme) + // macOS draws the window frame hairline and shadow using the NSWindow + // appearance, which follows nativeTheme rather than the rendered content. + // Align it with the app theme so a light app on a dark system does not get + // the dark-appearance border and shadow. A "system" scheme must map to + // "system" (not the resolved mode) or prefers-color-scheme stops tracking + // OS appearance changes in the renderer. + if (process.platform === "darwin") nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system" + updateTitlebar(win) +} + +export function updateTitlebar(win: BrowserWindow) { + if (process.platform !== "win32") return + win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor())) +} + +export function setPinchZoomEnabled(enabled: boolean) { + getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled) + for (const win of BrowserWindow.getAllWindows()) { + pinchZoomEnabled.set(win, enabled) + win.webContents.send("pinch-zoom-enabled-changed", enabled) + if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1) + updateZoom(win) + } +} + +export function getPinchZoomEnabled() { + return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true +} + +export function getWindowID(win: BrowserWindow) { + return windowIDs.get(win) +} + +export function getLastFocusedWindow() { + const focused = BrowserWindow.getFocusedWindow() + if (focused) return focused + const win = registry.lastFocused() + if (!win || win.isDestroyed()) return null + return win +} + +export function restoreMainWindows() { + const ids = registry.persisted() + return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id)) +} + +export function setDockIcon() { + if (process.platform !== "darwin") return + const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png")) + if (!icon.isEmpty()) app.dock?.setIcon(icon) +} + +export function createMainWindow(id: string = randomUUID()) { + const state = windowState({ + file: windowStateFile(id), + defaultWidth: 1280, + defaultHeight: 800, + }) + + const mode = tone() + const win = new BrowserWindow({ + x: state.x, + y: state.y, + width: state.width, + height: state.height, + show: false, + autoHideMenuBar: true, + title: "OpenCode", + icon: iconPath(), + backgroundColor: backgroundColor ?? defaultBackgroundColor(), + ...(process.platform === "darwin" + ? { + titleBarStyle: "hidden" as const, + trafficLightPosition: { x: 14, y: 14 }, + } + : {}), + ...(process.platform === "win32" + ? { + frame: false, + titleBarStyle: "hidden" as const, + titleBarOverlay: overlay({ mode }), + } + : {}), + webPreferences: { + preload: join(root, "../preload/index.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + + allowRendererPermissions(win) + wireWindowRecovery(win, id) + wireNavigationPolicy(win) + + win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { + const { requestHeaders } = details + upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"]) + callback({ requestHeaders }) + }) + + win.webContents.session.webRequest.onHeadersReceived((details, callback) => { + const { responseHeaders = {} } = details + addRendererHeaders(details.url, responseHeaders) + callback({ responseHeaders }) + }) + + state.manage(win) + registerWindow(win, id) + wireFullscreen(win) + loadWindow(win, "index.html") + wireZoom(win) + + win.once("ready-to-show", () => { + win.show() + }) + + return win +} + +export function openExternalURL(value: string) { + const url = resolveExternalURL(value) + if (!url) { + writeLog("window", "blocked external target", { url: value }, "warn") + return + } + void shell.openExternal(url) +} + +export function openLocalFileURL(value: string) { + const path = resolveLocalFilePath(value) + if (!path) { + writeLog("window", "blocked local file target", { url: value }, "warn") + return + } + void shell.openPath(path).then((error) => { + if (error) writeLog("window", "failed to open local file", { path, error }, "error") + }) +} + +function wireNavigationPolicy(win: BrowserWindow) { + win.webContents.setWindowOpenHandler(({ url }) => { + if (!isRendererUrl(url)) openExternalURL(url) + return { action: "deny" } + }) + // Renderer reloads (window.location.reload) navigate to the app's own URL + // and must stay in-window; everything else leaves through the OS. + win.webContents.on("will-navigate", (event, url) => { + if (isRendererUrl(url)) return + event.preventDefault() + openExternalURL(url) + }) +} + +function registerWindow(win: BrowserWindow, id: string) { + windowIDs.set(win, id) + registry.register(id, win) + + win.on("focus", () => registry.focused(id)) + // Windows never emits before-quit on OS shutdown/logoff, but each window + // gets session-end before it closes; flag the quit so ids stay persisted. + win.on("session-end", () => registry.setQuitting()) + win.on("closed", () => registry.closed(id)) +} + +function windowStateFile(id: string) { + return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json` +} + +// Mirrors windowStorage() in packages/app/src/utils/persist.ts, which names +// the per-window renderer store this window persists its tabs into. +function windowDataFile(id: string) { + return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat` +} + +export function registerRendererProtocol() { + if (protocol.isProtocolHandled(rendererProtocol)) return + + protocol.handle(rendererProtocol, async (request) => { + const url = new URL(request.url) + if (url.host !== rendererHost) { + writeLog("protocol", "rejected host", { url: request.url }, "warn") + return new Response("Not found", { status: 404 }) + } + + const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) + const rel = relative(rendererRoot, file) + if (rel.startsWith("..") || isAbsolute(rel)) { + writeLog("protocol", "rejected path", { url: request.url, file }, "warn") + return new Response("Not found", { status: 404 }) + } + + try { + const range = request.headers.get("range") + const response = await net.fetch(pathToFileURL(file).toString(), { + headers: range ? { range } : undefined, + }) + if (response.status >= 400) { + writeLog( + "protocol", + "fetch failed", + { + url: request.url, + file, + status: response.status, + statusText: response.statusText, + }, + "error", + ) + } + return addDocumentPolicy(response, file) + } catch (error) { + writeLog("protocol", "fetch error", { url: request.url, file, error }, "error") + return new Response("Not found", { status: 404 }) + } + }) +} + +function loadWindow(win: BrowserWindow, html: string) { + const devUrl = process.env.ELECTRON_RENDERER_URL + if (devUrl) { + const url = new URL(html, devUrl) + void win.loadURL(url.toString()) + return + } + + void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) +} + +function wireWindowRecovery(win: BrowserWindow, name: string) { + let showing = false + const sampler = createUnresponsiveSampler(win, name) + + type RecoveryAction = "relaunch" | "export-logs" | "keep-waiting" | "quit" + const handle = async (action: RecoveryAction | undefined, wait: boolean) => { + if (action === "export-logs") { + const sampling = sampler.stopAndFlush() + await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error")) + if (wait && sampling) sampler.start() + return true + } + if (action === "relaunch") { + sampler.stopAndFlush() + relaunchHandler() + return false + } + if (action === "quit") { + sampler.stopAndFlush() + app.quit() + } + return false + } + + const show = async (message: string, detail: string, wait: boolean) => { + if (showing || win.isDestroyed()) return + showing = true + try { + while (!win.isDestroyed()) { + const actions: { id: RecoveryAction; label: string }[] = wait + ? [ + { id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") }, + { id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") }, + { id: "keep-waiting", label: nativeT("desktop.recovery.action.keepWaiting") }, + ] + : [ + { id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") }, + { id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") }, + { id: "quit", label: nativeT("desktop.recovery.action.quit") }, + ] + const result = await dialog.showMessageBox(win, { + type: "warning", + buttons: actions.map((action) => action.label), + defaultId: 0, + cancelId: 2, + message, + detail, + }) + if (await handle(actions[result.response]?.id, wait)) continue + return + } + } finally { + showing = false + } + } + + const failed = ( + event: string, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + ) => { + writeLog( + "window", + "renderer load failed", + { + window: name, + event, + errorCode, + errorDescription, + validatedURL, + currentURL: safeWindowURL(win), + isMainFrame, + }, + "error", + ) + + if (!isMainFrame || errorCode === -3) return + void show( + nativeT("desktop.recovery.loadFailed"), + nativeT("desktop.recovery.loadFailed.detail", { + window: name, + url: validatedURL, + code: errorCode, + description: errorDescription, + }), + false, + ) + } + + win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame) + }) + win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame) + }) + win.webContents.on("render-process-gone", (_event, details) => { + sampler.stopAndFlush() + writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error") + void show( + nativeT("desktop.recovery.terminated"), + nativeT("desktop.recovery.terminated.detail", { + window: name, + reason: details.reason, + code: details.exitCode ?? nativeT("desktop.recovery.unknown"), + }), + false, + ) + }) + win.on("unresponsive", () => { + writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error") + sampler.start() + void show(nativeT("desktop.recovery.unresponsive"), nativeT("desktop.recovery.unresponsive.detail"), true) + }) + win.on("responsive", () => { + writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error") + sampler.stopAndFlush() + }) + win.webContents.on("console-message", (_event, level, message, line, sourceId) => { + if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) { + writeLog("pty", "console", { window: name, level, message, line, sourceId }) + } + }) + win.webContents.on("preload-error", (_event, preloadPath, error) => { + writeLog("preload", "preload error", { window: name, preloadPath, error }, "error") + }) +} + +function addDocumentPolicy(response: Response, file: string) { + if (!file.toLowerCase().endsWith(".html")) return response + const headers = new Headers(response.headers) + headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy) + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) +} + +function allowRendererPermissions(win: BrowserWindow) { + const webContentsId = win.webContents.id + + win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => { + callback( + rendererPermissions.has(permission) && + isTrustedRendererUrl(details.requestingUrl) && + webContents.id === webContentsId, + ) + }) + win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { + if (!rendererPermissions.has(permission)) return false + if (webContents && webContents.id !== webContentsId) return false + return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin) + }) +} + +function isTrustedRendererUrl(value?: string) { + return isRendererUrl(value) +} + +function addRendererHeaders(value: string, headers: Record) { + upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"]) + upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"]) + if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy]) +} + +function isRendererUrl(value?: string, html = false) { + if (!value || !URL.canParse(value)) return false + const url = new URL(value) + if (html && !url.pathname.endsWith(".html")) return false + if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true + const devUrl = process.env.ELECTRON_RENDERER_URL + if (!devUrl || !URL.canParse(devUrl)) return false + return url.origin === new URL(devUrl).origin +} + +function wireZoom(win: BrowserWindow) { + pinchZoomEnabled.set(win, getPinchZoomEnabled()) + win.webContents.setZoomFactor(1) + win.webContents.on("zoom-changed", (event, zoomDirection) => { + event.preventDefault() + if (pinchZoomEnabled.get(win)) { + win.webContents.setZoomFactor(clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2))) + updateZoom(win) + return + } + if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1) + updateZoom(win) + }) +} + +function wireFullscreen(win: BrowserWindow) { + const send = (fullscreen: boolean) => { + if (win.isDestroyed() || win.webContents.isDestroyed()) return + win.webContents.send("window-fullscreen-changed", fullscreen) + } + + win.on("enter-full-screen", () => send(true)) + win.on("leave-full-screen", () => send(false)) +} + +function clampZoom(value: number) { + return Math.min(Math.max(value, minZoomLevel), maxZoomLevel) +} + +function updateZoom(win: BrowserWindow) { + updateTitlebar(win) + win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor()) +} + +function upsertKeyValue(obj: Record, keyToChange: string, value: any) { + const keyToChangeLower = keyToChange.toLowerCase() + for (const key of Object.keys(obj)) { + if (key.toLowerCase() === keyToChangeLower) { + // Reassign old key + obj[key] = value + // Done + return + } + } + // Insert at end instead + obj[keyToChange] = value +} diff --git a/packages/desktop/src/main/wsl/ipc.ts b/packages/desktop/src/main/wsl/ipc.ts new file mode 100644 index 0000000000000000000000000000000000000000..839efad58538949c3502de7a8a0e22572d2508ef --- /dev/null +++ b/packages/desktop/src/main/wsl/ipc.ts @@ -0,0 +1,104 @@ +import { app, ipcMain } from "electron" +import type { IpcMainInvokeEvent } from "electron" +import type { WslServersController } from "./servers" +import { requireWslIpcString, requireWslIpcStrings } from "./policy" +import type { WslServersState } from "../../preload/types" +import { nativeT } from "../native-translations" + +export function registerWslIpcHandlers(controller: WslServersController) { + if (process.platform !== "win32") { + registerUnavailableWslIpcHandlers() + return + } + + const subscriptions = new Map void>() + const unsubscribe = (id: number) => { + const off = subscriptions.get(id) + if (!off) return + off() + subscriptions.delete(id) + } + + app.once("will-quit", () => { + subscriptions.forEach((off) => off()) + subscriptions.clear() + }) + + ipcMain.handle("wsl-servers-subscribe", (event) => { + const id = event.sender.id + if (subscriptions.has(id)) return + subscriptions.set( + id, + controller.subscribe((payload) => { + if (event.sender.isDestroyed()) { + unsubscribe(id) + return + } + event.sender.send("wsl-servers-event", payload) + }), + ) + event.sender.once("destroyed", () => unsubscribe(id)) + }) + ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id)) + ipcMain.handle("wsl-servers-get-state", () => controller.getState()) + ipcMain.handle("wsl-servers-probe-runtime", () => controller.probeRuntime()) + ipcMain.handle("wsl-servers-refresh-distros", () => controller.refreshDistros()) + ipcMain.handle("wsl-servers-install-wsl", () => controller.installWsl()) + ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => + controller.installDistro(requireWslIpcString("distro", name)), + ) + ipcMain.handle("wsl-servers-probe-addable", (_event: IpcMainInvokeEvent, distros: string[]) => + controller.probeAddable(requireWslIpcStrings("distro", distros)), + ) + ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => + controller.installOpencode(requireWslIpcString("distro", name)), + ) + ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => + controller.openTerminal(requireWslIpcString("distro", name)), + ) + ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => + controller.addServer(requireWslIpcString("distro", distro)), + ) + ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => + controller.removeServer(requireWslIpcString("server id", id)), + ) + ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => + controller.startServer(requireWslIpcString("server id", id)), + ) +} + +function registerUnavailableWslIpcHandlers() { + const unavailable = () => { + throw new Error(nativeT("desktop.wsl.error.windowsOnly")) + } + const state = (): WslServersState => ({ + runtime: { + available: false, + version: null, + error: nativeT("desktop.wsl.error.windowsOnly"), + }, + installed: [], + online: [], + distroProbes: {}, + opencodeChecks: {}, + pendingRestart: false, + servers: [], + job: null, + }) + + ipcMain.handle("wsl-servers-subscribe", (event) => { + event.sender.send("wsl-servers-event", { type: "state", state: state() }) + }) + ipcMain.handle("wsl-servers-unsubscribe", () => undefined) + ipcMain.handle("wsl-servers-get-state", () => state()) + ipcMain.handle("wsl-servers-probe-runtime", unavailable) + ipcMain.handle("wsl-servers-refresh-distros", unavailable) + ipcMain.handle("wsl-servers-install-wsl", unavailable) + ipcMain.handle("wsl-servers-install-distro", unavailable) + ipcMain.handle("wsl-servers-probe-addable", unavailable) + ipcMain.handle("wsl-servers-install-opencode", unavailable) + ipcMain.handle("wsl-servers-open-terminal", unavailable) + ipcMain.handle("wsl-servers-add", unavailable) + ipcMain.handle("wsl-servers-remove", unavailable) + ipcMain.handle("wsl-servers-start", unavailable) +} diff --git a/packages/desktop/src/main/wsl/policy.ts b/packages/desktop/src/main/wsl/policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ceee26c6b1beaa4a7e61a83a6a82a75a1e618f7 --- /dev/null +++ b/packages/desktop/src/main/wsl/policy.ts @@ -0,0 +1,33 @@ +import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types" + +export function wslServerIdToRestart(servers: WslServerItem[], distro: string) { + return servers.find((item) => item.config.distro === distro)?.config.id +} + +export function clearWslDistroState( + distroProbes: Record, + opencodeChecks: Record, + distro: string, +) { + const nextDistroProbes = { ...distroProbes } + const nextOpencodeChecks = { ...opencodeChecks } + delete nextDistroProbes[distro] + delete nextOpencodeChecks[distro] + return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks } +} + +export function wslTerminalArgs(distro?: string | null) { + return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])] +} + +export function requireWslIpcString(name: string, value: unknown) { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) +} + +export function requireWslIpcStrings(name: string, value: unknown) { + if (!Array.isArray(value)) throw new Error(`Invalid ${name}`) + const values = value.map((item) => requireWslIpcString(name, item)) + if (values.length > 0) return values + throw new Error(`Invalid ${name}`) +} diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffbc91fa3089939b5897e1c1b6829f3cb33b7ccc --- /dev/null +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -0,0 +1,405 @@ +import { spawn } from "node:child_process" +import { existsSync } from "node:fs" +import { join } from "node:path" +import * as pty from "@lydell/node-pty" +import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types" +import { wslTerminalArgs } from "./policy" +import { nativeT } from "../native-translations" + +export type WslCommandLine = { + stream: "stdout" | "stderr" + text: string +} + +export type WslCommandResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +export type RunWslOptions = { + signal?: AbortSignal + /** + * Ceiling on how long we wait for the child process to exit. When the + * LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a + * pending first-run prompt), `wsl.exe` never returns and any command + * that doesn't specify a timeout hangs the entire startup flow. Default + * is 20s — enough for slow cold-starts, short enough to fail fast on + * a wedge. Callers can override for longer-running jobs. + */ + timeoutMs?: number +} + +const DEFAULT_WSL_TIMEOUT_MS = 20_000 +const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000 + +export function wslArgs(args: string[], distro?: string | null, user?: string | null) { + return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args] +} + +export function runWsl(args: string[], opts: RunWslOptions = {}) { + return runCommand("wsl", args, opts) +} + +function runPowerShell(command: string, opts: RunWslOptions = {}) { + return runCommand( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command], + opts, + ) +} + +function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + signal: opts.signal, + }) + + // Guard every wsl.exe invocation with a timeout. When the distro or + // the LXSS service is wedged (Ubuntu first-run state, Windows update + // pending, etc.) wsl.exe produces no output and never exits; without + // this the whole sidecar spawn flow stalls the app forever. + const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS + const timeoutId = setTimeout(() => { + try { + child.kill() + } catch { + /* ignore */ + } + reject( + new Error(nativeT("desktop.wsl.error.commandTimeout", { command, args: args.join(" "), timeout: timeoutMs })), + ) + }, timeoutMs) + + let stdout = "" + let stderr = "" + const stdoutDecoder = createOutputDecoder() + const stderrDecoder = createOutputDecoder() + + const append = (stream: WslCommandLine["stream"], chunk: string) => { + if (!chunk) return + if (stream === "stdout") { + stdout += chunk + return + } + stderr += chunk + } + + child.stdout.on("data", (chunk: Buffer) => { + append("stdout", stdoutDecoder.decode(chunk)) + }) + child.stdout.on("end", () => { + append("stdout", stdoutDecoder.flush()) + }) + + child.stderr.on("data", (chunk: Buffer) => { + append("stderr", stderrDecoder.decode(chunk)) + }) + child.stderr.on("end", () => { + append("stderr", stderrDecoder.flush()) + }) + + child.once("error", (error) => { + clearTimeout(timeoutId) + reject(error) + }) + child.once("close", (code, signal) => { + clearTimeout(timeoutId) + resolve({ code, signal, stdout, stderr }) + }) + }) +} + +function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) { + return new Promise((resolve, reject) => { + const child = pty.spawn(command, args, { + name: "xterm-color", + cols: 80, + rows: 24, + cwd: process.cwd(), + env: process.env, + useConpty: true, + }) + + let settled = false + let stdout = "" + + const cleanup = () => { + clearTimeout(timeoutId) + abortCleanup?.() + } + + const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs + const timeoutId = setTimeout(() => { + try { + child.kill() + } catch { + /* ignore */ + } + if (settled) return + settled = true + cleanup() + reject( + new Error(nativeT("desktop.wsl.error.commandTimeout", { command, args: args.join(" "), timeout: timeoutMs })), + ) + }, timeoutMs) + + const abortHandler = () => { + try { + child.kill() + } catch { + /* ignore */ + } + if (settled) return + settled = true + cleanup() + reject(new DOMException("Aborted", "AbortError")) + } + const abortCleanup = opts.signal + ? (() => { + opts.signal?.addEventListener("abort", abortHandler, { once: true }) + return () => opts.signal?.removeEventListener("abort", abortHandler) + })() + : undefined + + child.onData((data: string) => { + stdout += data + }) + child.onExit((event: { exitCode: number }) => { + if (settled) return + settled = true + cleanup() + resolve({ code: event.exitCode, signal: null, stdout, stderr: "" }) + }) + }) +} + +function createOutputDecoder() { + let decoder: TextDecoder | undefined + return { + decode(chunk: Buffer) { + decoder ??= new TextDecoder(detectOutputEncoding(chunk)) + return decoder.decode(chunk, { stream: true }) + }, + flush() { + return decoder?.decode() ?? "" + }, + } +} + +function detectOutputEncoding(chunk: Uint8Array) { + if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le" + const pairs = Math.floor(chunk.length / 2) + if (pairs < 2) return "utf-8" + const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length + const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length + return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8" +} + +export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) { + return runWsl(wslArgs(args, distro), opts) +} + +export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { + return runWslInDistro(["sh", "-lc", script], distro, opts) +} + +export async function probeWslRuntime(opts?: RunWslOptions): Promise { + const version = await runWsl(["--version"], opts).catch((error) => ({ + code: 1, + signal: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) + + if (version.code !== 0) { + return { + available: false, + version: null, + error: summarize(version.stderr || version.stdout) || nativeT("desktop.wsl.error.unavailable"), + } + } + + return { + available: true, + version: firstLine(version.stdout), + error: null, + } +} + +export async function listInstalledWslDistros(opts?: RunWslOptions) { + const result = await runWsl(["--list", "--verbose"], opts) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.listInstalled")) + } + return parseInstalledDistros(result.stdout) +} + +export async function listOnlineWslDistros(opts?: RunWslOptions) { + const result = await runWsl(["--list", "--online"], opts) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.listOnline")) + } + return parseOnlineDistros(result.stdout) +} + +export async function installWslRuntimeElevated(opts?: RunWslOptions) { + const script = [ + "$ErrorActionPreference = 'Stop'", + "$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru", + "if ($null -ne $process.ExitCode) { exit $process.ExitCode }", + ].join("; ") + return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) +} + +export async function installWslDistro(name: string, opts?: RunWslOptions) { + return runInteractiveCommand( + resolveSystem32Command("wsl.exe"), + ["--install", "-d", name, "--web-download", "--no-launch"], + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + DEFAULT_WSL_INSTALL_TIMEOUT_MS, + ) +} + +export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) { + return runInteractiveCommand( + resolveSystem32Command("wsl.exe"), + wslArgs( + ["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`], + distro, + ), + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + DEFAULT_WSL_INSTALL_TIMEOUT_MS, + ) +} + +export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { + const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({ + code: 1, + signal: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) + if (executable.code !== 0) { + return { + name, + canExecute: false, + hasBash: false, + hasCurl: false, + error: summarize(executable.stderr || executable.stdout) || nativeT("desktop.wsl.error.executeDistro"), + } + } + + const [bash, curl] = await Promise.all([ + runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts), + runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts), + ]) + + return { + name, + canExecute: true, + hasBash: bash.code === 0 && summarize(bash.stdout) === "yes", + hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes", + error: null, + } +} + +export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { + return firstLine( + ( + await runWslSh( + 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', + distro, + opts, + ) + ).stdout, + ) +} + +export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) { + const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts) + return firstLine(result.stdout) +} + +export function openWslTerminal(distro?: string | null) { + return new Promise((resolve, reject) => { + const child = spawn("cmd.exe", wslTerminalArgs(distro), { + detached: true, + stdio: "ignore", + windowsHide: true, + }) + child.once("error", reject) + child.once("spawn", () => { + child.unref() + resolve() + }) + }) +} + +function parseInstalledDistros(output: string) { + return output.split(/\r?\n/g).flatMap((line) => { + const trimmed = line.trim() + if (!trimmed) return [] + const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/) + if (!match) return [] + const [, marker, name, version] = match + if (!name || /^name$/i.test(name)) return [] + return [ + { + name: name.trim(), + version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10), + isDefault: marker === "*", + } satisfies WslInstalledDistro, + ] + }) +} + +function parseOnlineDistros(output: string) { + return output.split(/\r?\n/g).flatMap((line) => { + const trimmed = line.trim() + if (!trimmed) return [] + const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/) + if (!match) return [] + const [, name, label] = match + if (/^name$/i.test(name)) return [] + return [{ name, label: label.trim() } satisfies WslOnlineDistro] + }) +} + +function firstLine(value: string) { + return ( + value + .split(/\r?\n/g) + .map((line) => line.trim()) + .find(Boolean) ?? null + ) +} + +export function summarize(value: string) { + return value + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n") +} + +export function shellEscape(value: string) { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + +function resolveSystem32Command(command: string) { + const root = process.env.SystemRoot ?? process.env.windir + if (!root) return command + const resolved = join(root, "System32", command) + return existsSync(resolved) ? resolved : command +} + +function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions { + return { + ...opts, + timeoutMs: opts?.timeoutMs ?? timeoutMs, + } +} diff --git a/packages/desktop/src/main/wsl/servers.test.ts b/packages/desktop/src/main/wsl/servers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..35b688d3c868b5166cccd584d70408489a9c438b --- /dev/null +++ b/packages/desktop/src/main/wsl/servers.test.ts @@ -0,0 +1,231 @@ +import { expect, test } from "bun:test" +import { + clearWslDistroState, + requireWslIpcString, + requireWslIpcStrings, + wslServerIdToRestart, + wslTerminalArgs, +} from "./policy" +import { + expectOpencodeVersion, + pendingRestartAfterWslInstall, + pollWslHealth, + wslServerIdsToStartOnInitialize, +} from "./startup" +import { createWslServersController, type WslServerConfig } from "./servers" + +let persistedServers: WslServerConfig[] = [] +let releaseOpencodeResolve: (() => void) | undefined + +test("starts every configured WSL server on initialization", () => { + expect( + wslServerIdsToStartOnInitialize([ + { id: "wsl:Debian", distro: "Debian" }, + { id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" }, + ]), + ).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"]) +}) + +test("rejects an update that did not install the desktop version", () => { + expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow() + expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow( + "OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2", + ) +}) + +test("restarts an existing distro server after updating OpenCode", () => { + expect( + wslServerIdToRestart( + [ + { + config: { id: "wsl:Debian", distro: "Debian" }, + runtime: { kind: "ready", url: "", username: null, password: null }, + }, + ], + "Debian", + ), + ).toBe("wsl:Debian") + expect(wslServerIdToRestart([], "Debian")).toBeUndefined() +}) + +test("clears cached distro probes when removing a WSL server", () => { + expect( + clearWslDistroState( + { Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } }, + { + Debian: { + distro: "Debian", + resolvedPath: "/home/luke/.opencode/bin/opencode", + version: "1.16.2", + expectedVersion: "1.16.2", + matchesDesktop: true, + error: null, + }, + }, + "Debian", + ), + ).toEqual({ distroProbes: {}, opencodeChecks: {} }) +}) + +test("opens terminals for distro names containing spaces", () => { + expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"]) +}) + +test("stops health polling when sidecar startup settles", async () => { + const abort = new AbortController() + let checks = 0 + const polling = pollWslHealth( + async () => { + checks++ + return false + }, + abort.signal, + 1, + ) + + await new Promise((resolve) => setTimeout(resolve, 5)) + abort.abort() + await polling + const settled = checks + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(checks).toBe(settled) +}) + +test("validates WSL IPC identifiers at the module boundary", () => { + expect(requireWslIpcString("distro", "Debian")).toBe("Debian") + expect(requireWslIpcStrings("distro", ["Debian", "Ubuntu"])).toEqual(["Debian", "Ubuntu"]) + expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro") + expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id") + expect(() => requireWslIpcStrings("distro", [])).toThrow("Invalid distro") +}) + +test("derives a required Windows restart from the post-install runtime probe", () => { + expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true) + expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false) +}) + +test("ignores stale background OpenCode checks after removing a WSL server", async () => { + persistedServers = [] + releaseOpencodeResolve = undefined + const controller = createWslServersController( + "1.16.2", + async () => ({ + listener: { + stop: () => undefined, + onExit: () => undefined, + }, + url: "http://127.0.0.1:4096", + username: "opencode", + password: "secret", + }), + testControllerOptions(), + ) + + await controller.addServer("Debian") + await waitFor(() => !!releaseOpencodeResolve) + await controller.removeServer("wsl:Debian") + releaseOpencodeResolve?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(controller.getState().servers).toEqual([]) + expect(controller.getState().opencodeChecks).toEqual({}) +}) + +test("ignores stale startup OpenCode checks after removing a WSL server", async () => { + persistedServers = [{ id: "wsl:Debian", distro: "Debian" }] + releaseOpencodeResolve = undefined + const controller = createWslServersController( + "1.16.2", + async () => new Promise(() => undefined), + testControllerOptions(), + ) + + await controller.initialize() + await waitFor(() => !!releaseOpencodeResolve) + await controller.removeServer("wsl:Debian") + releaseOpencodeResolve?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(controller.getState().servers).toEqual([]) + expect(controller.getState().opencodeChecks).toEqual({}) +}) + +test("probes addable distros in parallel before checking OpenCode", async () => { + persistedServers = [] + const started: string[] = [] + const release = new Map void>() + const opencode: string[] = [] + const controller = createWslServersController("1.16.2", async () => new Promise(() => undefined), { + ...testControllerOptions(), + probeDistro: async (distro) => { + started.push(distro) + await new Promise((resolve) => release.set(distro, resolve)) + return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null } + }, + resolveOpencode: async (distro) => { + opencode.push(distro) + return "/home/me/.opencode/bin/opencode" + }, + }) + + const task = controller.probeAddable(["Debian", "Ubuntu"]) + await waitFor(() => started.length === 2) + expect(started).toEqual(["Debian", "Ubuntu"]) + expect(opencode).toEqual([]) + release.get("Debian")?.() + release.get("Ubuntu")?.() + await task + + expect(Object.keys(controller.getState().distroProbes)).toEqual(["Debian", "Ubuntu"]) + expect(opencode).toEqual(["Debian", "Ubuntu"]) + expect(Object.keys(controller.getState().opencodeChecks)).toEqual(["Debian", "Ubuntu"]) +}) + +test("does not check OpenCode in addable distros that cannot execute commands", async () => { + persistedServers = [] + const opencode: string[] = [] + const controller = createWslServersController("1.16.2", async () => new Promise(() => undefined), { + ...testControllerOptions(), + probeDistro: async (distro) => ({ + name: distro, + canExecute: distro === "Debian", + hasBash: distro === "Debian", + hasCurl: distro === "Debian", + error: distro === "Debian" ? null : "Open Ubuntu once to finish setup", + }), + resolveOpencode: async (distro) => { + opencode.push(distro) + return "/home/me/.opencode/bin/opencode" + }, + }) + + await controller.probeAddable(["Debian", "Ubuntu"]) + + expect(Object.keys(controller.getState().distroProbes)).toEqual(["Debian", "Ubuntu"]) + expect(opencode).toEqual(["Debian"]) + expect(Object.keys(controller.getState().opencodeChecks)).toEqual(["Debian"]) +}) + +async function waitFor(check: () => boolean) { + for (let attempt = 0; attempt < 20; attempt++) { + if (check()) return + await new Promise((resolve) => setTimeout(resolve, 0)) + } + throw new Error("Timed out waiting for condition") +} + +function testControllerOptions() { + return { + readServers: () => persistedServers, + writeServers: (servers: WslServerConfig[]) => { + persistedServers = servers + }, + readCommandVersion: async () => "1.16.2", + resolveOpencode: async () => { + await new Promise((resolve) => { + releaseOpencodeResolve = resolve + }) + return "/home/me/.opencode/bin/opencode" + }, + } +} diff --git a/packages/desktop/src/main/wsl/servers.ts b/packages/desktop/src/main/wsl/servers.ts new file mode 100644 index 0000000000000000000000000000000000000000..749f51431f491031b5ccc6372038aca8f28cd569 --- /dev/null +++ b/packages/desktop/src/main/wsl/servers.ts @@ -0,0 +1,523 @@ +import type { + WslDistroProbe, + WslInstalledDistro, + WslJob, + WslOnlineDistro, + WslOpencodeCheck, + WslRuntimeCheck, + WslServerConfig, + WslServerItem, + WslServerRuntime, + WslServersEvent, + WslServersState, +} from "../../preload/types" +import { WSL_SERVERS_KEY } from "../store-keys" +import { getStore } from "../store" +import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup" +import { clearWslDistroState, wslServerIdToRestart } from "./policy" +import { nativeT } from "../native-translations" +import { + installWslDistro, + installWslOpencode, + installWslRuntimeElevated, + listInstalledWslDistros, + listOnlineWslDistros, + openWslTerminal, + probeWslDistro, + probeWslRuntime, + readWslCommandVersion, + resolveWslOpencode, + summarize, +} from "./runtime" + +type RunningSidecar = { + listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } + url: string + username: string | null + password: string +} + +type SpawnSidecar = (distro: string) => Promise + +type ControllerLogger = { + log: (message: string, meta?: unknown) => void + error: (message: string, meta?: unknown) => void +} + +type WslServersControllerOptions = { + logger?: ControllerLogger + readServers?: () => WslServerConfig[] + writeServers?: (servers: WslServerConfig[]) => void + probeDistro?: typeof probeWslDistro + resolveOpencode?: typeof resolveWslOpencode + readCommandVersion?: typeof readWslCommandVersion +} + +export type WslServersController = ReturnType + +export function wslServerIdForDistro(distro: string) { + return `wsl:${distro}` +} + +export function createWslServersController( + appVersion: string, + spawnSidecar: SpawnSidecar, + options?: WslServersControllerOptions, +) { + let state: WslServersState = initialState() + const listeners = new Set<(event: WslServersEvent) => void>() + const sidecars = new Map() + const startAttempts = new Map() + let jobAbort: AbortController | undefined + const logger = options?.logger + const readServers = options?.readServers ?? readPersistedServers + const writeServers = options?.writeServers ?? writePersistedServers + const probeDistro = options?.probeDistro ?? probeWslDistro + + const emit = () => { + for (const listener of listeners) listener({ type: "state", state }) + } + + const setState = (next: Partial) => { + state = { ...state, ...next } + emit() + } + + const persistServers = (servers: WslServerConfig[]) => { + writeServers(servers) + } + + const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => { + const next = state.servers.map((item) => (item.config.id === id ? update(item) : item)) + setState({ servers: next }) + } + + const beginJob = (job: WslJob): AbortController => { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + setState({ job }) + return abort + } + + const endJob = (abort: AbortController) => { + if (jobAbort !== abort) return + jobAbort = undefined + setState({ job: null }) + } + + const refreshFromStore = () => { + const persisted = readServers() + const items: WslServerItem[] = persisted.map((config) => { + const existing = state.servers.find((item) => item.config.id === config.id) + return { + config, + runtime: existing?.runtime ?? { kind: "stopped" }, + } + }) + setState({ servers: items }) + } + + const setRuntime = (id: string, runtime: WslServerRuntime) => { + updateServer(id, (item) => ({ ...item, runtime })) + } + + const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => { + setState({ + opencodeChecks: { + ...state.opencodeChecks, + [distro]: check, + }, + }) + } + + const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => { + const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts) + const version = resolved + ? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts) + : null + return opencodeCheck(distro, resolved, version, appVersion) + } + + const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => { + setOpencodeCheck(distro, await checkOpencode(distro, opts)) + } + + const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => { + const unique = [...new Set(distros)] + const distroProbes = await Promise.all( + unique + .filter((distro) => !state.distroProbes[distro]) + .map(async (distro) => [distro, await probeDistro(distro, opts)] as const), + ) + if (distroProbes.length) { + setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } }) + } + + const opencodeChecks = await Promise.all( + unique + .filter((distro) => distroProbeReady(state.distroProbes[distro])) + .filter((distro) => !state.opencodeChecks[distro]) + .map(async (distro) => [distro, await checkOpencode(distro, opts)] as const), + ) + if (opencodeChecks.length) { + setState({ opencodeChecks: { ...state.opencodeChecks, ...Object.fromEntries(opencodeChecks) } }) + } + } + + const hasServer = (id: string, distro: string) => { + return state.servers.some((item) => item.config.id === id && item.config.distro === distro) + } + + const refreshOpencodeCheckBackground = (id: string, distro: string) => { + void checkOpencode(distro) + .then((check) => { + if (!hasServer(id, distro)) return + setOpencodeCheck(distro, check) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + logger?.error("wsl opencode check failed", { id, distro, message }) + }) + } + + const refreshOpencodeChecks = async () => { + await Promise.all( + state.servers.map((item) => + checkOpencode(item.config.distro) + .then((check) => { + if (!hasServer(item.config.id, item.config.distro)) return + setOpencodeCheck(item.config.distro, check) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + logger?.error("wsl opencode check failed", { + id: item.config.id, + distro: item.config.distro, + message, + }) + }), + ), + ) + } + + const refreshDistroLists = async (opts: { signal?: AbortSignal }) => { + const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)]) + return { installed, online } + } + + const nextStartAttempt = (id: string) => { + const next = (startAttempts.get(id) ?? 0) + 1 + startAttempts.set(id, next) + return next + } + + const invalidateStartAttempt = (id: string) => { + startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1) + } + + const isCurrentStartAttempt = (id: string, attempt: number) => { + return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id) + } + + const startServer = async (id: string) => { + const item = state.servers.find((x) => x.config.id === id) + if (!item) return + const attempt = nextStartAttempt(id) + await stopServerInternal(id) + if (!isCurrentStartAttempt(id, attempt)) return + setRuntime(id, { kind: "starting" }) + logger?.log("wsl sidecar starting", { id, distro: item.config.distro }) + try { + const sidecar = await spawnSidecar(item.config.distro) + if (!isCurrentStartAttempt(id, attempt)) { + try { + sidecar.listener.stop() + } catch { + // ignore stop errors for stale sidecars + } + return + } + sidecars.set(id, sidecar) + setRuntime(id, { + kind: "ready", + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, + }) + sidecar.listener.onExit((code, signal) => { + if (sidecars.get(id) !== sidecar) return + sidecars.delete(id) + const message = startupFailure(code, signal) + setRuntime(id, { kind: "failed", message }) + logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) + }) + refreshOpencodeCheckBackground(id, item.config.distro) + logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!isCurrentStartAttempt(id, attempt)) return + setRuntime(id, { kind: "failed", message }) + // Without this, an Ubuntu-style silent failure leaves no trace in + // main.log — the controller captures the message in its state but + // nothing surfaces unless the user opens the WSL servers dialog. + logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) + } + } + + const stopServerInternal = async (id: string) => { + const existing = sidecars.get(id) + if (!existing) return + sidecars.delete(id) + try { + existing.listener.stop() + } catch { + // ignore stop errors + } + } + + const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => { + const abort = beginJob(job) + try { + const value = await runner(abort) + endJob(abort) + return value + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + endJob(abort) + return undefined + } + const err = error instanceof Error ? error : new Error(String(error)) + endJob(abort) + throw err + } + } + + return { + getState() { + return state + }, + subscribe(listener: (event: WslServersEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + + async initialize() { + refreshFromStore() + void refreshOpencodeChecks() + for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id) + }, + + async probeRuntime() { + await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => { + const runtime = await probeWslRuntime({ signal: abort.signal }) + setState({ + runtime, + pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false, + }) + }) + }, + + async refreshDistros() { + await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { + setState(await refreshDistroLists({ signal: abort.signal })) + }) + }, + + async installWsl() { + await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { + const result = await installWslRuntimeElevated({ signal: abort.signal }) + if (result.code !== 0) { + const message = summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installWsl") + throw new Error(message) + } + const runtime = await probeWslRuntime({ signal: abort.signal }) + setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) }) + }) + }, + + async installDistro(name: string) { + await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { + const result = await installWslDistro(name, { signal: abort.signal }) + if (result.code !== 0) { + const message = + summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installDistro", { distro: name }) + throw new Error(message) + } + const distros = await refreshDistroLists({ signal: abort.signal }) + const probe = await probeDistro(name, { signal: abort.signal }) + setState({ + ...distros, + distroProbes: { ...state.distroProbes, [name]: probe }, + }) + }) + }, + + async probeAddable(distros: string[]) { + if (!distros.length) return + await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => { + await probeAddableDistros(distros, { signal: abort.signal }) + }) + }, + + async installOpencode(name: string) { + await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { + const result = await installWslOpencode(appVersion, name, { signal: abort.signal }) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode")) + } + await refreshOpencodeCheck(name, { signal: abort.signal }) + expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name) + const id = wslServerIdToRestart(state.servers, name) + if (id) await startServer(id) + }) + }, + + async openTerminal(name: string) { + await openWslTerminal(name) + }, + + async addServer(distro: string): Promise { + const id = wslServerIdForDistro(distro) + if (state.servers.some((item) => item.config.id === id)) { + throw new Error(nativeT("desktop.wsl.error.alreadyAdded", { distro })) + } + const config: WslServerConfig = { + id, + distro, + } + persistServers([...readServers(), config]) + setState({ + servers: [...state.servers, { config, runtime: { kind: "starting" } }], + }) + void startServer(id) + return config + }, + + async removeServer(id: string) { + const distro = state.servers.find((item) => item.config.id === id)?.config.distro + invalidateStartAttempt(id) + await stopServerInternal(id) + const remaining = readServers().filter((item) => item.id !== id) + persistServers(remaining) + setState({ + servers: state.servers.filter((item) => item.config.id !== id), + ...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}), + }) + }, + + startServer, + + stopAll() { + for (const item of state.servers) invalidateStartAttempt(item.config.id) + for (const existing of sidecars.values()) { + try { + existing.listener.stop() + } catch { + // ignore + } + } + sidecars.clear() + }, + } +} + +function initialState(): WslServersState { + return { + runtime: null, + installed: [], + online: [], + distroProbes: {}, + opencodeChecks: {}, + pendingRestart: false, + servers: [], + job: null, + } +} + +function readPersistedServers(): WslServerConfig[] { + const store = getStore() + const existing = store.get(WSL_SERVERS_KEY) + if (existing && typeof existing === "object") { + const record = existing as { servers?: unknown } + const list = Array.isArray(record.servers) ? record.servers : [] + return list.flatMap(normalizePersistedServer) + } + return [] +} + +function writePersistedServers(servers: WslServerConfig[]) { + getStore().set(WSL_SERVERS_KEY, { servers }) +} + +function normalizePersistedServer(value: unknown): WslServerConfig[] { + if (!value || typeof value !== "object") return [] + const record = value as Record + const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null + if (!distro) return [] + const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro) + return [ + { + id, + distro, + }, + ] +} + +function opencodeCheck( + distro: string, + resolvedPath: string | null, + version: string | null, + expectedVersion: string, +): WslOpencodeCheck { + if (!resolvedPath) { + return { + distro, + resolvedPath: null, + version: null, + expectedVersion, + matchesDesktop: null, + error: nativeT("desktop.wsl.error.opencodeMissing"), + } + } + if (!version) { + return { + distro, + resolvedPath, + version: null, + expectedVersion, + matchesDesktop: null, + error: nativeT("desktop.wsl.error.opencodeCannotRun"), + } + } + return { + distro, + resolvedPath, + version, + expectedVersion, + matchesDesktop: version === expectedVersion, + error: null, + } +} + +function distroProbeReady(probe: WslDistroProbe | undefined) { + return !!probe?.canExecute && probe.hasBash && probe.hasCurl +} + +function startupFailure(code: number | null, signal: NodeJS.Signals | null) { + return nativeT("desktop.wsl.error.serverExited", { code: code ?? "null", signal: signal ?? "null" }) +} + +// Re-export types used by callers +export type { + WslInstalledDistro, + WslOnlineDistro, + WslRuntimeCheck, + WslDistroProbe, + WslOpencodeCheck, + WslServerConfig, + WslServerItem, + WslServerRuntime, + WslServersEvent, + WslServersState, +} diff --git a/packages/desktop/src/main/wsl/sidecar.ts b/packages/desktop/src/main/wsl/sidecar.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd052dd84648d063cb2ca3dbbf65e8bbb584e7fa --- /dev/null +++ b/packages/desktop/src/main/wsl/sidecar.ts @@ -0,0 +1,134 @@ +import { spawn } from "node:child_process" +import { randomUUID } from "node:crypto" +import { createServer } from "node:net" +import { app } from "electron" +import { checkHealth } from "../server" +import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime" +import { pollWslHealth } from "./startup" +import { nativeT } from "../native-translations" + +export type WslSidecar = { + listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } + url: string + username: string | null + password: string +} + +export async function spawnWslSidecar( + distro: string, + opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, +): Promise { + const opencode = await resolveWslOpencode(distro) + if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro })) + + const port = await allocatePort() + const password = randomUUID() + const username = "opencode" + const script = [ + "set -euo pipefail", + 'cd "$HOME" || cd /', + 'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")', + "export PATH", + "export WSLENV=", + "export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true", + "export OPENCODE_CLIENT=desktop", + `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`, + `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, + 'export XDG_STATE_HOME="$HOME/.local/state"', + `exec ${shellEscape(opencode)} --print-logs --log-level ${app.isPackaged ? "WARN" : "INFO"} serve --hostname 0.0.0.0 --port ${port}`, + ].join("\n") + const child = spawn("wsl", wslArgs(["bash", "-se"], distro), { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }) + child.stdin.end(script) + + const recentOutput: string[] = [] + const emit = (line: WslCommandLine) => { + if (!line.text.trim()) return + recentOutput.push(`[${line.stream}] ${line.text}`) + if (recentOutput.length > 12) recentOutput.shift() + opts.onLine?.(line) + } + forwardLines(child.stdout, "stdout", emit) + forwardLines(child.stderr, "stderr", emit) + + const exit = new Promise((_, reject) => { + child.once("error", reject) + child.once("exit", (code, signal) => reject(new Error(startupFailure(code, signal, recentOutput)))) + }) + const url = `http://127.0.0.1:${port}` + const startup = new AbortController() + const health = pollWslHealth(() => checkHealth(url, password), startup.signal) + const timeoutMs = opts.healthTimeoutMs ?? 30_000 + let timeout: ReturnType + const timedOut = new Promise( + (_, reject) => + (timeout = setTimeout( + () => reject(new Error(nativeT("desktop.wsl.error.healthTimeout", { distro, timeout: timeoutMs }))), + timeoutMs, + )), + ) + + await Promise.race([health, exit, timedOut]) + .catch((error) => { + child.kill() + throw error + }) + .finally(() => { + clearTimeout(timeout) + startup.abort() + }) + return { + listener: { + stop: () => child.kill(), + onExit: (cb) => child.once("exit", cb), + }, + url, + username, + password, + } +} + +function allocatePort() { + return new Promise((resolve, reject) => { + const server = createServer() + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (typeof address !== "object" || !address) { + server.close() + reject(new Error(nativeT("desktop.wsl.error.failedPort"))) + return + } + server.close(() => resolve(address.port)) + }) + }) +} + +function forwardLines( + stream: NodeJS.ReadableStream, + source: WslCommandLine["stream"], + onLine: (line: WslCommandLine) => void, +) { + let pending = "" + stream.setEncoding("utf8") + stream.on("data", (chunk: string) => { + pending += chunk + const lines = pending.split(/\r?\n/g) + pending = lines.pop() ?? "" + lines.forEach((text) => onLine({ stream: source, text })) + }) + stream.on("end", () => { + if (pending) onLine({ stream: source, text: pending }) + }) +} + +function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) { + const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : "" + return nativeT("desktop.wsl.error.serverExitedBeforeHealthy", { + code: code ?? "null", + signal: signal ?? "null", + output: suffix, + }) +} diff --git a/packages/desktop/src/main/wsl/startup.ts b/packages/desktop/src/main/wsl/startup.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a80c3d613f4d394c2abe236e33c663e42261076 --- /dev/null +++ b/packages/desktop/src/main/wsl/startup.ts @@ -0,0 +1,37 @@ +import { nativeT } from "../native-translations" + +export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) { + return servers.map((server) => server.id) +} + +export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") { + if (installed === expected) return + throw new Error( + nativeT("desktop.wsl.error.updateVersion", { + distro, + installed: installed ?? nativeT("desktop.wsl.error.noVersion"), + expected, + }), + ) +} + +export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available + +export async function pollWslHealth(check: () => Promise, signal: AbortSignal, interval = 100) { + while (!signal.aborted) { + if (await check()) return + await abortableDelay(interval, signal) + } +} + +function abortableDelay(duration: number, signal: AbortSignal) { + return new Promise((resolve) => { + const done = () => { + clearTimeout(timeout) + signal.removeEventListener("abort", done) + resolve() + } + const timeout = setTimeout(done, duration) + signal.addEventListener("abort", done, { once: true }) + }) +} diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..dae18b0716a430432201b764fa4c7c4f0a8d6453 --- /dev/null +++ b/packages/desktop/src/preload/index.ts @@ -0,0 +1,138 @@ +import { contextBridge, ipcRenderer, webUtils } from "electron" +import type { ElectronAPI, WslServersEvent } from "./types" +import type { UpdaterState } from "@opencode-ai/app/updater" + +const updaterCallbacks = new Set<(state: UpdaterState) => void>() +let updaterState: UpdaterState | undefined +let updaterSubscription: Promise | undefined +const updaterHandler = (_: unknown, state: UpdaterState) => { + updaterState = state + updaterCallbacks.forEach((callback) => callback(state)) +} + +const api: ElectronAPI = { + killSidecar: () => ipcRenderer.invoke("kill-sidecar"), + installCli: () => ipcRenderer.invoke("install-cli"), + awaitInitialization: () => ipcRenderer.invoke("await-initialization"), + wslServers: { + getState: () => ipcRenderer.invoke("wsl-servers-get-state"), + subscribe: (cb) => { + const handler = (_: unknown, event: WslServersEvent) => cb(event) + ipcRenderer.on("wsl-servers-event", handler) + void ipcRenderer.invoke("wsl-servers-subscribe") + return () => { + ipcRenderer.removeListener("wsl-servers-event", handler) + void ipcRenderer.invoke("wsl-servers-unsubscribe") + } + }, + probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"), + refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"), + installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"), + installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name), + probeAddable: (distros) => ipcRenderer.invoke("wsl-servers-probe-addable", distros), + installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name), + openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name), + addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro), + removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id), + startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id), + }, + updater: { + subscribe: async (cb) => { + updaterCallbacks.add(cb) + if (updaterState) cb(updaterState) + if (!updaterSubscription) { + ipcRenderer.on("updater-state", updaterHandler) + updaterSubscription = ipcRenderer.invoke("updater-subscribe") + } + await updaterSubscription + return () => { + updaterCallbacks.delete(cb) + if (updaterCallbacks.size > 0) return + ipcRenderer.removeListener("updater-state", updaterHandler) + updaterSubscription = undefined + void ipcRenderer.invoke("updater-unsubscribe") + } + }, + check: () => ipcRenderer.invoke("updater-check"), + install: () => ipcRenderer.invoke("updater-install"), + }, + consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"), + getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), + setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), + isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"), + finishFirstLaunchOnboarding: (createDefaultProject) => + ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject), + isOldLayoutEligible: () => ipcRenderer.invoke("is-old-layout-eligible"), + getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"), + setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend), + checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName), + resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName), + storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key), + storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value), + storeDelete: (name, key) => ipcRenderer.invoke("store-delete", name, key), + storeClear: (name) => ipcRenderer.invoke("store-clear", name), + storeKeys: (name) => ipcRenderer.invoke("store-keys", name), + storeLength: (name) => ipcRenderer.invoke("store-length", name), + draftGet: (key) => ipcRenderer.invoke("draft-get", key), + draftSet: (key, value) => ipcRenderer.invoke("draft-set", key, value), + draftDelete: (key) => ipcRenderer.invoke("draft-delete", key), + draftBlobPut: (data) => ipcRenderer.invoke("draft-blob-put", data), + draftBlobGet: (id) => ipcRenderer.invoke("draft-blob-get", id), + + getWindowID: () => ipcRenderer.invoke("get-window-id"), + onMenuCommand: (cb) => { + const handler = (_: unknown, id: string) => cb(id) + ipcRenderer.on("menu-command", handler) + return () => ipcRenderer.removeListener("menu-command", handler) + }, + onDeepLink: (cb) => { + const handler = (_: unknown, urls: string[]) => cb(urls) + ipcRenderer.on("deep-link", handler) + return () => ipcRenderer.removeListener("deep-link", handler) + }, + + openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts), + openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts), + readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path), + releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token), + getPathForFile: (file) => webUtils.getPathForFile(file), + saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts), + openExternal: (url) => ipcRenderer.send("open-external", url), + openLocalFile: (url) => ipcRenderer.send("open-local-file", url), + openPath: (path, app) => ipcRenderer.invoke("open-path", path, app), + revealPath: (path) => ipcRenderer.invoke("reveal-path", path), + readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"), + getWindowFocused: () => ipcRenderer.invoke("get-window-focused"), + getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"), + onWindowFullscreenChanged: (cb) => { + const handler = (_: unknown, fullscreen: boolean) => cb(fullscreen) + ipcRenderer.on("window-fullscreen-changed", handler) + return () => ipcRenderer.removeListener("window-fullscreen-changed", handler) + }, + setWindowFocus: () => ipcRenderer.invoke("set-window-focus"), + showWindow: () => ipcRenderer.invoke("show-window"), + relaunch: () => ipcRenderer.send("relaunch"), + getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), + setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), + getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"), + setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled), + onPinchZoomEnabledChanged: (cb) => { + const handler = (_: unknown, enabled: boolean) => cb(enabled) + ipcRenderer.on("pinch-zoom-enabled-changed", handler) + return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler) + }, + onZoomFactorChanged: (cb) => { + const handler = (_: unknown, factor: number) => cb(factor) + ipcRenderer.on("zoom-factor-changed", handler) + return () => ipcRenderer.removeListener("zoom-factor-changed", handler) + }, + setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme), + runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action), + setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), + exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"), + setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled), + recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error), + setNativeTranslations: (bundle) => ipcRenderer.invoke("set-native-translations", bundle), +} + +contextBridge.exposeInMainWorld("api", api) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..20c39097d31d34493303f017f9a2122a2735d976 --- /dev/null +++ b/packages/desktop/src/preload/types.ts @@ -0,0 +1,116 @@ +import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" +import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" +import type { UpdaterState } from "@opencode-ai/app/updater" +import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" +export type { + WslDistroProbe, + WslInstalledDistro, + WslJob, + WslOnlineDistro, + WslOpencodeCheck, + WslRuntimeCheck, + WslServerConfig, + WslServerItem, + WslServerRuntime, + WslServersEvent, + WslServersState, +} from "@opencode-ai/app/wsl/types" + +export type ServerReadyData = { + url: string + username: string | null + password: string | null +} + +export type WslServersAPI = WslServersPlatform +export type UpdaterAPI = { + subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void> + check: () => Promise + install: () => Promise +} + +export type LinuxDisplayBackend = "wayland" | "auto" +export type TitlebarTheme = { + mode: "light" | "dark" + scheme?: "system" | "light" | "dark" +} +export type FatalRendererError = { + error: string + url: string + version?: string + platform: string + os?: string +} + +export type ElectronAPI = { + killSidecar: () => Promise + installCli: () => Promise + awaitInitialization: () => Promise + wslServers: WslServersAPI + updater: UpdaterAPI + consumeInitialDeepLinks: () => Promise + getDefaultServerUrl: () => Promise + setDefaultServerUrl: (url: string | null) => Promise + isFirstLaunchOnboardingPending: () => Promise + finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise + isOldLayoutEligible: () => Promise + getDisplayBackend: () => Promise + setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise + checkAppExists: (appName: string) => Promise + resolveAppPath: (appName: string) => Promise + storeGet: (name: string, key: string) => Promise + storeSet: (name: string, key: string, value: string) => Promise + storeDelete: (name: string, key: string) => Promise + storeClear: (name: string) => Promise + storeKeys: (name: string) => Promise + storeLength: (name: string) => Promise + draftGet: (key: string) => Promise + draftSet: (key: string, value: string) => Promise + draftDelete: (key: string) => Promise + draftBlobPut: (data: ArrayBuffer) => Promise + draftBlobGet: (id: string) => Promise + + getWindowID: () => Promise + onMenuCommand: (cb: (id: string) => void) => () => void + onDeepLink: (cb: (urls: string[]) => void) => () => void + + openDirectoryPicker: (opts?: { + multiple?: boolean + title?: string + defaultPath?: string + }) => Promise + openFilePicker: (opts?: { + multiple?: boolean + title?: string + defaultPath?: string + extensions?: string[] + }) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null> + readPickedFile: (token: string, path: string) => Promise + releasePickedFiles: (token: string) => Promise + getPathForFile: (file: File) => string + saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise + openExternal: (url: string) => void + openLocalFile: (url: string) => void + openPath: (path: string, app?: string) => Promise + revealPath: (path: string) => Promise + readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null> + getWindowFocused: () => Promise + getWindowFullscreen: () => Promise + onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void + setWindowFocus: () => Promise + showWindow: () => Promise + relaunch: () => void + getZoomFactor: () => Promise + setZoomFactor: (factor: number) => Promise + getPinchZoomEnabled: () => Promise + setPinchZoomEnabled: (enabled: boolean) => Promise + onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void + onZoomFactorChanged: (cb: (factor: number) => void) => () => void + setTitlebar: (theme: TitlebarTheme) => Promise + runDesktopMenuAction: (action: DesktopMenuAction) => Promise + setBackgroundColor: (color: string) => Promise + exportDebugLogs: () => Promise + setForceFocus: (enabled: boolean) => Promise + recordFatalRendererError: (error: FatalRendererError) => Promise + setNativeTranslations: (bundle: DesktopNativeBundle) => Promise +} diff --git a/packages/desktop/src/renderer/cli.ts b/packages/desktop/src/renderer/cli.ts new file mode 100644 index 0000000000000000000000000000000000000000..11d3c1f1b08bcb43bcec68530909bfb58cba0872 --- /dev/null +++ b/packages/desktop/src/renderer/cli.ts @@ -0,0 +1,12 @@ +import { initI18n, t } from "./i18n" + +export async function installCli(): Promise { + await initI18n() + + try { + const path = await window.api.installCli() + window.alert(t("desktop.cli.installed.message", { path })) + } catch (e) { + window.alert(t("desktop.cli.failed.message", { error: String(e) })) + } +} diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..6dff3baf1c494621090237e3429019b15f3faa6a --- /dev/null +++ b/packages/desktop/src/renderer/env.d.ts @@ -0,0 +1,10 @@ +import type { ElectronAPI } from "../preload/types" + +declare global { + interface Window { + api: ElectronAPI + __OPENCODE__?: { + deepLinks?: string[] + } + } +} diff --git a/packages/desktop/src/renderer/html.test.ts b/packages/desktop/src/renderer/html.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a332070ee9f6e6b8b02233a23352ca9b8b8ff511 --- /dev/null +++ b/packages/desktop/src/renderer/html.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { join, dirname, resolve } from "node:path" +import { existsSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const dir = dirname(fileURLToPath(import.meta.url)) +const root = resolve(dir, "../..") + +const html = async (name: string) => Bun.file(join(dir, name)).text() + +/** + * Packaged Electron windows load renderer HTML via the privileged `oc://` + * protocol. Root-relative asset paths like `src="/foo.js"` would resolve from + * the protocol origin root instead of relative to the current HTML entrypoint. + * + * All local resource references must use relative paths (`./`). + */ +describe("electron renderer html", () => { + for (const name of ["index.html"]) { + describe(name, () => { + test("script src attributes use relative paths", async () => { + const content = await html(name) + const srcs = [...content.matchAll(/\bsrc=["']([^"']+)["']/g)].map((m) => m[1]) + for (const src of srcs) { + expect(src).not.toMatch(/^\/[^/]/) + } + }) + + test("link href attributes use relative paths", async () => { + const content = await html(name) + const hrefs = [...content.matchAll(/]+href=["']([^"']+)["']/g)].map((m) => m[1]) + for (const href of hrefs) { + expect(href).not.toMatch(/^\/[^/]/) + } + }) + + test("no web manifest link (not applicable in Electron)", async () => { + const content = await html(name) + expect(content).not.toContain('rel="manifest"') + }) + }) + } +}) + +/** + * Vite resolves `publicDir` relative to `root`, not the config file. + * This test reads the actual values from electron.vite.config.ts to catch + * regressions where the publicDir path no longer resolves correctly + * after the renderer root is accounted for. + */ +describe("electron vite publicDir", () => { + test("configured publicDir resolves to a directory with oc-theme-preload.js", async () => { + const config = await Bun.file(join(root, "electron.vite.config.ts")).text() + const pub = config.match(/publicDir:\s*["']([^"']+)["']/) + const rendererRoot = config.match(/root:\s*["']([^"']+)["']/) + expect(pub).not.toBeNull() + expect(rendererRoot).not.toBeNull() + const resolved = resolve(root, rendererRoot![1], pub![1]) + expect(existsSync(resolved)).toBe(true) + expect(existsSync(join(resolved, "oc-theme-preload.js"))).toBe(true) + }) +}) diff --git a/packages/desktop/src/renderer/i18n/ar.ts b/packages/desktop/src/renderer/i18n/ar.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a0bce51bb660fbc49e2d822478c85987371fd7a --- /dev/null +++ b/packages/desktop/src/renderer/i18n/ar.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...", + "desktop.menu.installCli": "تثبيت CLI...", + "desktop.menu.reloadWebview": "إعادة تحميل عرض الويب", + "desktop.menu.restart": "إعادة تشغيل", + + "desktop.dialog.chooseFolder": "اختيار مجلد", + "desktop.dialog.chooseFile": "اختيار ملف", + "desktop.dialog.saveFile": "حفظ ملف", + + "desktop.updater.checkFailed.title": "فشل التحقق من التحديثات", + "desktop.updater.checkFailed.message": "فشل التحقق من وجود تحديثات", + "desktop.updater.none.title": "لا توجد تحديثات متاحة", + "desktop.updater.none.message": "أنت تستخدم بالفعل أحدث إصدار من OpenCode", + "desktop.updater.downloadFailed.title": "فشل التحديث", + "desktop.updater.downloadFailed.message": "فشل تنزيل التحديث", + "desktop.updater.downloaded.title": "تم تنزيل التحديث", + "desktop.updater.downloaded.prompt": + "تم تنزيل الإصدار {{version}} من OpenCode. هل ترغب في تثبيته وإعادة تشغيل التطبيق؟", + "desktop.updater.installFailed.title": "فشل التحديث", + "desktop.updater.installFailed.message": "فشل تثبيت التحديث", + + "desktop.cli.installed.title": "تم تثبيت CLI", + "desktop.cli.installed.message": "تم تثبيت CLI في {{path}}\n\nأعد تشغيل الطرفية لاستخدام الأمر 'opencode'.", + "desktop.cli.failed.title": "فشل التثبيت", + "desktop.cli.failed.message": "فشل تثبيت CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟", +} diff --git a/packages/desktop/src/renderer/i18n/bn.ts b/packages/desktop/src/renderer/i18n/bn.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ae424fe5275e46f8c8310e5525b732458f676ef --- /dev/null +++ b/packages/desktop/src/renderer/i18n/bn.ts @@ -0,0 +1,27 @@ +export const dict: Record = { + "desktop.menu.checkForUpdates": "আপডেটের জন্য চেক করুন...", + "desktop.menu.installCli": "CLI ইনস্টল করুন...", + "desktop.menu.reloadWebview": "Webview পুনরায় লোড করুন", + "desktop.menu.restart": "রিস্টার্ট করুন", + "desktop.dialog.chooseFolder": "একটি ফোল্ডার নির্বাচন করুন", + "desktop.dialog.chooseFile": "একটি ফাইল নির্বাচন করুন", + "desktop.dialog.saveFile": "ফাইল সংরক্ষণ করুন", + "desktop.updater.checkFailed.title": "আপডেট চেক ব্যর্থ হয়েছে", + "desktop.updater.checkFailed.message": "আপডেটের জন্য চেক করতে ব্যর্থ", + "desktop.updater.none.title": "কোন আপডেট উপলব্ধ নেই", + "desktop.updater.none.message": "আপনি ইতিমধ্যেই OpenCode এর সর্বশেষ সংস্করণ ব্যবহার করছেন৷", + "desktop.updater.downloadFailed.title": "আপডেট ব্যর্থ হয়েছে৷", + "desktop.updater.downloadFailed.message": "আপডেট ডাউনলোড করতে ব্যর্থ হয়েছে", + "desktop.updater.downloaded.title": "আপডেট ডাউনলোড হয়েছে", + "desktop.updater.downloaded.prompt": + "OpenCode-এর {{version}} সংস্করণ ডাউনলোড করা হয়েছে, আপনি কি এটি ইনস্টল করে পুনরায় চালু করতে চান?", + "desktop.updater.installFailed.title": "আপডেট ব্যর্থ হয়েছে৷", + "desktop.updater.installFailed.message": "আপডেট ইনস্টল করতে ব্যর্থ হয়েছে", + "desktop.cli.installed.title": "CLI ইনস্টল করা হয়েছে", + "desktop.cli.installed.message": "CLI {{path}}\n\n'ওপেনকোড' কমান্ড ব্যবহার করতে আপনার টার্মিনাল পুনরায় চালু করুন।", + "desktop.cli.failed.title": "ইনস্টলেশন ব্যর্থ হয়েছে", + "desktop.cli.failed.message": "CLI ইনস্টল করতে ব্যর্থ: {{error}}", + + "desktop.error.dev.rootNotFound": + "মূল উপাদান পাওয়া যায়নি. আপনি কি আপনার index.html এ যোগ করতে ভুলে গেছেন? অথবা হয়তো আইডি অ্যাট্রিবিউট ভুল বানান হয়েছে?", +} diff --git a/packages/desktop/src/renderer/i18n/br.ts b/packages/desktop/src/renderer/i18n/br.ts new file mode 100644 index 0000000000000000000000000000000000000000..444e05dda5fa2e3c37f30362819a065cda99cfd1 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/br.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Verificar atualizações...", + "desktop.menu.installCli": "Instalar CLI...", + "desktop.menu.reloadWebview": "Recarregar Webview", + "desktop.menu.restart": "Reiniciar", + + "desktop.dialog.chooseFolder": "Escolher uma pasta", + "desktop.dialog.chooseFile": "Escolher um arquivo", + "desktop.dialog.saveFile": "Salvar arquivo", + + "desktop.updater.checkFailed.title": "Falha ao verificar atualizações", + "desktop.updater.checkFailed.message": "Falha ao verificar atualizações", + "desktop.updater.none.title": "Nenhuma atualização disponível", + "desktop.updater.none.message": "Você já está usando a versão mais recente do OpenCode", + "desktop.updater.downloadFailed.title": "Falha na atualização", + "desktop.updater.downloadFailed.message": "Falha ao baixar a atualização", + "desktop.updater.downloaded.title": "Atualização baixada", + "desktop.updater.downloaded.prompt": + "A versão {{version}} do OpenCode foi baixada. Você gostaria de instalá-la e reiniciar?", + "desktop.updater.installFailed.title": "Falha na atualização", + "desktop.updater.installFailed.message": "Falha ao instalar a atualização", + + "desktop.cli.installed.title": "CLI instalada", + "desktop.cli.installed.message": "CLI instalada em {{path}}\n\nReinicie seu terminal para usar o comando 'opencode'.", + "desktop.cli.failed.title": "Falha na instalação", + "desktop.cli.failed.message": "Falha ao instalar a CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?", +} diff --git a/packages/desktop/src/renderer/i18n/bs.ts b/packages/desktop/src/renderer/i18n/bs.ts new file mode 100644 index 0000000000000000000000000000000000000000..54a606d78bce63e5a281daabcab0e9b6542c58fa --- /dev/null +++ b/packages/desktop/src/renderer/i18n/bs.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Provjeri ažuriranja...", + "desktop.menu.installCli": "Instaliraj CLI...", + "desktop.menu.reloadWebview": "Ponovo učitaj Webview", + "desktop.menu.restart": "Ponovo pokreni", + + "desktop.dialog.chooseFolder": "Odaberi fasciklu", + "desktop.dialog.chooseFile": "Odaberi datoteku", + "desktop.dialog.saveFile": "Sačuvaj datoteku", + + "desktop.updater.checkFailed.title": "Provjera ažuriranja nije uspjela", + "desktop.updater.checkFailed.message": "Nije moguće provjeriti ažuriranja", + "desktop.updater.none.title": "Nema dostupnog ažuriranja", + "desktop.updater.none.message": "Već koristiš najnoviju verziju OpenCode-a", + "desktop.updater.downloadFailed.title": "Ažuriranje nije uspjelo", + "desktop.updater.downloadFailed.message": "Neuspjelo preuzimanje ažuriranja", + "desktop.updater.downloaded.title": "Ažuriranje preuzeto", + "desktop.updater.downloaded.prompt": + "Verzija {{version}} OpenCode-a je preuzeta. Želiš li da je instaliraš i ponovo pokreneš aplikaciju?", + "desktop.updater.installFailed.title": "Ažuriranje nije uspjelo", + "desktop.updater.installFailed.message": "Neuspjela instalacija ažuriranja", + + "desktop.cli.installed.title": "CLI instaliran", + "desktop.cli.installed.message": + "CLI je instaliran u {{path}}\n\nPonovo pokreni terminal da bi koristio komandu 'opencode'.", + "desktop.cli.failed.title": "Instalacija nije uspjela", + "desktop.cli.failed.message": "Neuspjela instalacija CLI-a: {{error}}", + + "desktop.error.dev.rootNotFound": + "Korijenski element nije pronađen. Da li si zaboravio da ga dodaš u index.html? Ili je možda id atribut pogrešno napisan?", +} diff --git a/packages/desktop/src/renderer/i18n/ca.ts b/packages/desktop/src/renderer/i18n/ca.ts new file mode 100644 index 0000000000000000000000000000000000000000..534c64ae70b4b39da389999ccf1a35e3355c717c --- /dev/null +++ b/packages/desktop/src/renderer/i18n/ca.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Comproveu si hi ha actualitzacions...", + "desktop.menu.installCli": "Instal·la CLI...", + "desktop.menu.reloadWebview": "Torna a carregar Webview", + "desktop.menu.restart": "Reinicia", + "desktop.dialog.chooseFolder": "Trieu una carpeta", + "desktop.dialog.chooseFile": "Trieu un fitxer", + "desktop.dialog.saveFile": "Desa el fitxer", + "desktop.updater.checkFailed.title": "La comprovació d'actualització ha fallat", + "desktop.updater.checkFailed.message": "No s'ha pogut comprovar si hi ha actualitzacions", + "desktop.updater.none.title": "No hi ha cap actualització disponible", + "desktop.updater.none.message": "Ja utilitzeu la versió més recent d'OpenCode", + "desktop.updater.downloadFailed.title": "L'actualització ha fallat", + "desktop.updater.downloadFailed.message": "No s'ha pogut descarregar l'actualització", + "desktop.updater.downloaded.title": "Actualització baixada", + "desktop.updater.downloaded.prompt": + "S'ha baixat la versió {{version}} d'OpenCode. Voleu instal·lar-la i reiniciar l'aplicació?", + "desktop.updater.installFailed.title": "L'actualització ha fallat", + "desktop.updater.installFailed.message": "No s'ha pogut instal·lar l'actualització", + "desktop.cli.installed.title": "CLI instal·lada", + "desktop.cli.installed.message": + "CLI instal·lada a {{path}}\n\nReinicieu el terminal per utilitzar l'ordre 'opencode'.", + "desktop.cli.failed.title": "La instal·lació ha fallat", + "desktop.cli.failed.message": "No s'ha pogut instal·lar CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "No s'ha trobat l'element arrel. T'has oblidat d'afegir-lo al teu index.html? O potser l'atribut id s'ha escrit malament?", +} diff --git a/packages/desktop/src/renderer/i18n/cs.ts b/packages/desktop/src/renderer/i18n/cs.ts new file mode 100644 index 0000000000000000000000000000000000000000..72980fb96e711802af934eedb582884da5e212ee --- /dev/null +++ b/packages/desktop/src/renderer/i18n/cs.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Zkontrolovat aktualizace...", + "desktop.menu.installCli": "Instalovat CLI...", + "desktop.menu.reloadWebview": "Znovu načíst Webview", + "desktop.menu.restart": "Restartovat", + "desktop.dialog.chooseFolder": "Vyberte složku", + "desktop.dialog.chooseFile": "Vyberte soubor", + "desktop.dialog.saveFile": "Uložit soubor", + "desktop.updater.checkFailed.title": "Kontrola aktualizace se nezdařila", + "desktop.updater.checkFailed.message": "Kontrola aktualizací se nezdařila", + "desktop.updater.none.title": "Není k dispozici žádná aktualizace", + "desktop.updater.none.message": "Již používáte nejnovější verzi OpenCode", + "desktop.updater.downloadFailed.title": "Aktualizace se nezdařila", + "desktop.updater.downloadFailed.message": "Stažení aktualizace se nezdařilo", + "desktop.updater.downloaded.title": "Aktualizace stažena", + "desktop.updater.downloaded.prompt": + "Byla stažena verze {{version}} aplikace OpenCode. Chcete ji nainstalovat a aplikaci znovu spustit?", + "desktop.updater.installFailed.title": "Aktualizace se nezdařila", + "desktop.updater.installFailed.message": "Aktualizaci se nepodařilo nainstalovat", + "desktop.cli.installed.title": "CLI nainstalováno", + "desktop.cli.installed.message": + "CLI nainstalováno do {{path}}\n\nRestartujte svůj terminál, abyste mohli použít příkaz 'opencode'.", + "desktop.cli.failed.title": "Instalace se nezdařila", + "desktop.cli.failed.message": "Instalace CLI se nezdařila: {{error}}", + + "desktop.error.dev.rootNotFound": + "Kořenový prvek nenalezen. Zapomněli jste to přidat do index.html? Nebo je možná chyba v atributu id?", +} diff --git a/packages/desktop/src/renderer/i18n/da.ts b/packages/desktop/src/renderer/i18n/da.ts new file mode 100644 index 0000000000000000000000000000000000000000..38bff2cc84f959874c1bbb1a53729c70824f5a4b --- /dev/null +++ b/packages/desktop/src/renderer/i18n/da.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Tjek for opdateringer...", + "desktop.menu.installCli": "Installer CLI...", + "desktop.menu.reloadWebview": "Genindlæs webvisning", + "desktop.menu.restart": "Genstart", + + "desktop.dialog.chooseFolder": "Vælg en mappe", + "desktop.dialog.chooseFile": "Vælg en fil", + "desktop.dialog.saveFile": "Gem fil", + + "desktop.updater.checkFailed.title": "Opdateringstjek mislykkedes", + "desktop.updater.checkFailed.message": "Kunne ikke tjekke for opdateringer", + "desktop.updater.none.title": "Ingen opdatering tilgængelig", + "desktop.updater.none.message": "Du bruger allerede den nyeste version af OpenCode", + "desktop.updater.downloadFailed.title": "Opdatering mislykkedes", + "desktop.updater.downloadFailed.message": "Kunne ikke downloade opdateringen", + "desktop.updater.downloaded.title": "Opdatering downloadet", + "desktop.updater.downloaded.prompt": + "Version {{version}} af OpenCode er blevet downloadet. Vil du installere den og genstarte?", + "desktop.updater.installFailed.title": "Opdatering mislykkedes", + "desktop.updater.installFailed.message": "Kunne ikke installere opdateringen", + + "desktop.cli.installed.title": "CLI installeret", + "desktop.cli.installed.message": + "CLI installeret i {{path}}\n\nGenstart din terminal for at bruge 'opencode'-kommandoen.", + "desktop.cli.failed.title": "Installation mislykkedes", + "desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Rodelement ikke fundet. Har du glemt at tilføje det til din index.html? Eller måske er id-attributten stavet forkert?", +} diff --git a/packages/desktop/src/renderer/i18n/de.ts b/packages/desktop/src/renderer/i18n/de.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7c970bcbcb08a5232459f1848d3335e68f1c48c --- /dev/null +++ b/packages/desktop/src/renderer/i18n/de.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Nach Updates suchen…", + "desktop.menu.installCli": "CLI installieren…", + "desktop.menu.reloadWebview": "Webview neu laden", + "desktop.menu.restart": "Neustart", + + "desktop.dialog.chooseFolder": "Ordner auswählen", + "desktop.dialog.chooseFile": "Datei auswählen", + "desktop.dialog.saveFile": "Datei speichern", + + "desktop.updater.checkFailed.title": "Updateprüfung fehlgeschlagen", + "desktop.updater.checkFailed.message": "Updates konnten nicht geprüft werden", + "desktop.updater.none.title": "Kein Update verfügbar", + "desktop.updater.none.message": "Sie verwenden bereits die neueste Version von OpenCode", + "desktop.updater.downloadFailed.title": "Update fehlgeschlagen", + "desktop.updater.downloadFailed.message": "Update konnte nicht heruntergeladen werden", + "desktop.updater.downloaded.title": "Update heruntergeladen", + "desktop.updater.downloaded.prompt": + "Version {{version}} von OpenCode wurde heruntergeladen. Möchten Sie sie installieren und neu starten?", + "desktop.updater.installFailed.title": "Update fehlgeschlagen", + "desktop.updater.installFailed.message": "Update konnte nicht installiert werden", + + "desktop.cli.installed.title": "CLI installiert", + "desktop.cli.installed.message": + "CLI wurde in {{path}} installiert\n\nStarten Sie Ihr Terminal neu, um den Befehl 'opencode' zu verwenden.", + "desktop.cli.failed.title": "Installation fehlgeschlagen", + "desktop.cli.failed.message": "CLI konnte nicht installiert werden: {{error}}", + + "desktop.error.dev.rootNotFound": + "Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das ID-Attribut falsch geschrieben?", +} diff --git a/packages/desktop/src/renderer/i18n/dv.ts b/packages/desktop/src/renderer/i18n/dv.ts new file mode 100644 index 0000000000000000000000000000000000000000..04266acfc55e7fff8ea3a853b7573c37652ec3ad --- /dev/null +++ b/packages/desktop/src/renderer/i18n/dv.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "އަޕްޑޭޓްސް އަށް ޗެކް ކޮށްލައްވާ...", + "desktop.menu.installCli": "CLI އިންސްޓޯލް ކުރާށެވެ...", + "desktop.menu.reloadWebview": "ވެބްވިއު ރީލޯޑް ކުރާށެވެ", + "desktop.menu.restart": "އަލުން ފަށާށެވެ", + "desktop.dialog.chooseFolder": "ފޯލްޑަރެއް ހޮވާށެވެ", + "desktop.dialog.chooseFile": "ފައިލެއް ހޮވާށެވެ", + "desktop.dialog.saveFile": "ފައިލް ސޭވްކުރުން", + "desktop.updater.checkFailed.title": "އަޕްޑޭޓް ޗެކް ފެއިލްވެއްޖެ", + "desktop.updater.checkFailed.message": "އަޕްޑޭޓްތައް ޗެކް ނުކުރެވުނެވެ", + "desktop.updater.none.title": "އެއްވެސް އަޕްޑޭޓެއް ނުލިބެއެވެ", + "desktop.updater.none.message": "މިހާރުވެސް ބޭނުން ކުރަމުންދަނީ OpenCode ގެ އެންމެ ފަހުގެ ވަރޝަން އެވެ", + "desktop.updater.downloadFailed.title": "އަޕްޑޭޓް ފެއިލްވެއްޖެ", + "desktop.updater.downloadFailed.message": "އަޕްޑޭޓް ޑައުންލޯޑް ނުކުރެވުނެވެ", + "desktop.updater.downloaded.title": "އަޕްޑޭޓް ޑައުންލޯޑް ކުރެވިއްޖެއެވެ", + "desktop.updater.downloaded.prompt": + "OpenCode ގެ ވަރޝަން {{version}} ޑައުންލޯޑް ކުރެވިއްޖެ، އިންސްޓޯލްކޮށް އަލުން ލޯންޗް ކުރަން ބޭނުން ހެއްޔެވެ؟", + "desktop.updater.installFailed.title": "އަޕްޑޭޓް ފެއިލްވެއްޖެ", + "desktop.updater.installFailed.message": "އަޕްޑޭޓް އިންސްޓޯލް ކުރަން ނާކާމިޔާބުވިއެވެ", + "desktop.cli.installed.title": "CLI އިންސްޓޯލް ކުރެވިއްޖެއެވެ", + "desktop.cli.installed.message": + "CLI އިންސްޓޯލްކޮށްފައިވަނީ {{path}} އަށެވެ\n\n'opencode' ކޮމާންޑް ބޭނުން ކުރުމަށް ޓާމިނަލް އަލުން ސްޓާޓް ކުރާށެވެ.", + "desktop.cli.failed.title": "އިންސްޓޯލް ކުރުން ފެއިލްވެއްޖެ", + "desktop.cli.failed.message": "CLI: {{error}} އިންސްޓޯލް ކުރަން ނާކާމިޔާބު", + + "desktop.error.dev.rootNotFound": + "ރޫޓް އެލިމެންޓް ނުފެނެއެވެ. ތިބާގެ index.html އަށް އެޑް ކުރަން ހަނދާން ނެތުނީ ހެއްޔެވެ؟ ނުވަތަ id އެޓްރިބިއުޓް ގޯސްކޮށް އިމްތިހާނު ވެދާނެ ހެއްޔެވެ؟", +} diff --git a/packages/desktop/src/renderer/i18n/el.ts b/packages/desktop/src/renderer/i18n/el.ts new file mode 100644 index 0000000000000000000000000000000000000000..a19eb7260d3ee00d7f6257123e14e626a54e136d --- /dev/null +++ b/packages/desktop/src/renderer/i18n/el.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Έλεγχος για ενημερώσεις...", + "desktop.menu.installCli": "Εγκατάσταση CLI...", + "desktop.menu.reloadWebview": "Επανάληψη φόρτωσης Webview", + "desktop.menu.restart": "Επανεκκίνηση", + "desktop.dialog.chooseFolder": "Επιλογή φακέλου", + "desktop.dialog.chooseFile": "Επιλογή αρχείου", + "desktop.dialog.saveFile": "Αποθήκευση αρχείου", + "desktop.updater.checkFailed.title": "Ο έλεγχος ενημέρωσης απέτυχε", + "desktop.updater.checkFailed.message": "Απέτυχε ο έλεγχος για ενημερώσεις", + "desktop.updater.none.title": "Δεν υπάρχει διαθέσιμη ενημέρωση", + "desktop.updater.none.message": "Χρησιμοποιείτε ήδη την πιο πρόσφατη έκδοση του OpenCode", + "desktop.updater.downloadFailed.title": "Η ενημέρωση απέτυχε", + "desktop.updater.downloadFailed.message": "Απέτυχε η λήψη της ενημέρωσης", + "desktop.updater.downloaded.title": "Η ενημέρωση λήφθηκε", + "desktop.updater.downloaded.prompt": + "Έχει γίνει λήψη της έκδοσης {{version}} του OpenCode. Θέλετε να την εγκαταστήσετε και να επανεκκινήσετε την εφαρμογή;", + "desktop.updater.installFailed.title": "Η ενημέρωση απέτυχε", + "desktop.updater.installFailed.message": "Αποτυχία εγκατάστασης ενημέρωσης", + "desktop.cli.installed.title": "Το CLI εγκαταστάθηκε", + "desktop.cli.installed.message": + "CLI εγκατεστημένο στο {{path}}\n\nΕπανεκκινήστε το τερματικό σας για να χρησιμοποιήσετε την εντολή 'opencode'.", + "desktop.cli.failed.title": "Η εγκατάσταση απέτυχε", + "desktop.cli.failed.message": "Απέτυχε η εγκατάσταση του CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Το στοιχείο ρίζας δεν βρέθηκε. Ξεχάσατε να το προσθέσετε στο index.html; Ή μήπως το χαρακτηριστικό id γράφτηκε λάθος;", +} diff --git a/packages/desktop/src/renderer/i18n/en.ts b/packages/desktop/src/renderer/i18n/en.ts new file mode 100644 index 0000000000000000000000000000000000000000..95528b4d31818a83ce1c519e569b424aa6832e20 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/en.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Check for Updates...", + "desktop.menu.installCli": "Install CLI...", + "desktop.menu.reloadWebview": "Reload Webview", + "desktop.menu.restart": "Restart", + + "desktop.dialog.chooseFolder": "Choose a folder", + "desktop.dialog.chooseFile": "Choose a file", + "desktop.dialog.saveFile": "Save file", + + "desktop.updater.checkFailed.title": "Update Check Failed", + "desktop.updater.checkFailed.message": "Failed to check for updates", + "desktop.updater.none.title": "No Update Available", + "desktop.updater.none.message": "You are already using the latest version of OpenCode", + "desktop.updater.downloadFailed.title": "Update Failed", + "desktop.updater.downloadFailed.message": "Failed to download update", + "desktop.updater.downloaded.title": "Update Downloaded", + "desktop.updater.downloaded.prompt": + "Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?", + "desktop.updater.installFailed.title": "Update Failed", + "desktop.updater.installFailed.message": "Failed to install update", + + "desktop.cli.installed.title": "CLI Installed", + "desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.", + "desktop.cli.failed.title": "Installation Failed", + "desktop.cli.failed.message": "Failed to install CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", +} diff --git a/packages/desktop/src/renderer/i18n/es.ts b/packages/desktop/src/renderer/i18n/es.ts new file mode 100644 index 0000000000000000000000000000000000000000..320aa3fa59c1dbc1a1fb91c9aadd65149a9c6a18 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/es.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Buscar actualizaciones...", + "desktop.menu.installCli": "Instalar CLI...", + "desktop.menu.reloadWebview": "Recargar vista web", + "desktop.menu.restart": "Reiniciar", + + "desktop.dialog.chooseFolder": "Elegir una carpeta", + "desktop.dialog.chooseFile": "Elegir un archivo", + "desktop.dialog.saveFile": "Guardar archivo", + + "desktop.updater.checkFailed.title": "Comprobación de actualizaciones fallida", + "desktop.updater.checkFailed.message": "No se pudieron buscar actualizaciones", + "desktop.updater.none.title": "No hay actualizaciones disponibles", + "desktop.updater.none.message": "Ya estás usando la versión más reciente de OpenCode", + "desktop.updater.downloadFailed.title": "Actualización fallida", + "desktop.updater.downloadFailed.message": "No se pudo descargar la actualización", + "desktop.updater.downloaded.title": "Actualización descargada", + "desktop.updater.downloaded.prompt": + "Se ha descargado la versión {{version}} de OpenCode. ¿Quieres instalarla y reiniciar?", + "desktop.updater.installFailed.title": "Actualización fallida", + "desktop.updater.installFailed.message": "No se pudo instalar la actualización", + + "desktop.cli.installed.title": "CLI instalada", + "desktop.cli.installed.message": "CLI instalada en {{path}}\n\nReinicia tu terminal para usar el comando 'opencode'.", + "desktop.cli.failed.title": "Instalación fallida", + "desktop.cli.failed.message": "No se pudo instalar la CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Elemento raíz no encontrado. ¿Olvidaste añadirlo a tu index.html? ¿O tal vez el atributo id está mal escrito?", +} diff --git a/packages/desktop/src/renderer/i18n/fa.ts b/packages/desktop/src/renderer/i18n/fa.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5c169190fe45f5f2bb369497435aa4b16301bd6 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/fa.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "بررسی به روز رسانی...", + "desktop.menu.installCli": "نصب CLI...", + "desktop.menu.reloadWebview": "بارگذاری مجدد Webview", + "desktop.menu.restart": "راه اندازی مجدد", + "desktop.dialog.chooseFolder": "یک پوشه را انتخاب کنید", + "desktop.dialog.chooseFile": "یک فایل را انتخاب کنید", + "desktop.dialog.saveFile": "ذخیره فایل", + "desktop.updater.checkFailed.title": "بررسی به‌روزرسانی انجام نشد", + "desktop.updater.checkFailed.message": "بررسی به‌روزرسانی‌ها انجام نشد", + "desktop.updater.none.title": "به روز رسانی موجود نیست", + "desktop.updater.none.message": "شما در حال حاضر از آخرین نسخه OpenCode استفاده می کنید", + "desktop.updater.downloadFailed.title": "به روز رسانی انجام نشد", + "desktop.updater.downloadFailed.message": "به روز رسانی دانلود نشد", + "desktop.updater.downloaded.title": "به روز رسانی دانلود شد", + "desktop.updater.downloaded.prompt": + "نسخه {{version}} OpenCode دانلود شده است، آیا می خواهید آن را نصب کنید و دوباره راه اندازی کنید؟", + "desktop.updater.installFailed.title": "به روز رسانی انجام نشد", + "desktop.updater.installFailed.message": "به روز رسانی نصب نشد", + "desktop.cli.installed.title": "CLI نصب شده است", + "desktop.cli.installed.message": + "CLI روی {{path}} نصب شد\n\nترمینال خود را مجددا راه اندازی کنید تا از دستور 'opencode' استفاده کنید.", + "desktop.cli.failed.title": "نصب ناموفق بود", + "desktop.cli.failed.message": "CLI نصب نشد: {{error}}", + + "desktop.error.dev.rootNotFound": + "عنصر ریشه یافت نشد. آیا فراموش کرده اید که آن را به index.html خود اضافه کنید؟ یا شاید ویژگی id اشتباه املایی داشته باشد؟", +} diff --git a/packages/desktop/src/renderer/i18n/fi.ts b/packages/desktop/src/renderer/i18n/fi.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd213c08c2c2ccf12a895b482648f3a87389ea09 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/fi.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Tarkista päivitykset...", + "desktop.menu.installCli": "Asenna CLI...", + "desktop.menu.reloadWebview": "Lataa verkkonäkymä uudelleen", + "desktop.menu.restart": "Käynnistä uudelleen", + "desktop.dialog.chooseFolder": "Valitse kansio", + "desktop.dialog.chooseFile": "Valitse tiedosto", + "desktop.dialog.saveFile": "Tallenna tiedosto", + "desktop.updater.checkFailed.title": "Päivitystarkistus epäonnistui", + "desktop.updater.checkFailed.message": "Päivitysten tarkistaminen epäonnistui", + "desktop.updater.none.title": "Päivitystä ei ole saatavilla", + "desktop.updater.none.message": "Käytät jo OpenCoden uusinta versiota", + "desktop.updater.downloadFailed.title": "Päivitys epäonnistui", + "desktop.updater.downloadFailed.message": "Päivityksen lataaminen epäonnistui", + "desktop.updater.downloaded.title": "Päivitys ladattu", + "desktop.updater.downloaded.prompt": + "OpenCoden versio {{version}} on ladattu. Haluatko asentaa sen ja käynnistää OpenCoden uudelleen?", + "desktop.updater.installFailed.title": "Päivitys epäonnistui", + "desktop.updater.installFailed.message": "Päivityksen asentaminen epäonnistui", + "desktop.cli.installed.title": "CLI on asennettu", + "desktop.cli.installed.message": + "CLI on asennettu polkuun {{path}}\n\nKäynnistä terminaali uudelleen, jotta voit käyttää 'opencode'-komentoa.", + "desktop.cli.failed.title": "Asennus epäonnistui", + "desktop.cli.failed.message": "CLI:n asennus epäonnistui: {{error}}", + + "desktop.error.dev.rootNotFound": + "Juurielementtiä ei löydy. Unohditko lisätä sen index.html-tiedostoosi? Tai ehkä id-attribuutti on kirjoitettu väärin?", +} diff --git a/packages/desktop/src/renderer/i18n/fo.ts b/packages/desktop/src/renderer/i18n/fo.ts new file mode 100644 index 0000000000000000000000000000000000000000..c154202c1b9c9f08ff81aa6dc582d1f7becbc41f --- /dev/null +++ b/packages/desktop/src/renderer/i18n/fo.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Kanna fyri dagføringum...", + "desktop.menu.installCli": "Set upp CLI...", + "desktop.menu.reloadWebview": "Endurlesa vevvísing", + "desktop.menu.restart": "Endurbyrja", + "desktop.dialog.chooseFolder": "Vel eina mappu", + "desktop.dialog.chooseFile": "Vel eina fílu", + "desktop.dialog.saveFile": "Goym fílu", + "desktop.updater.checkFailed.title": "Dagføringarkanningin miseydnaðist", + "desktop.updater.checkFailed.message": "Tað eydnaðist ikki at kanna fyri dagføringum", + "desktop.updater.none.title": "Eingin dagføring er tøk", + "desktop.updater.none.message": "Tú brúkar longu nýggjastu útgávuna av OpenCode.", + "desktop.updater.downloadFailed.title": "Dagføring miseydnaðist", + "desktop.updater.downloadFailed.message": "Tað eydnaðist ikki at heinta dagføring", + "desktop.updater.downloaded.title": "Dagføring heintað", + "desktop.updater.downloaded.prompt": + "Útgáva {{version}} av OpenCode er heintað, vilt tú seta hana upp og seta hana í gongd aftur?", + "desktop.updater.installFailed.title": "Dagføring miseydnaðist", + "desktop.updater.installFailed.message": "Tað eydnaðist ikki at seta upp dagføring", + "desktop.cli.installed.title": "CLI Sett upp", + "desktop.cli.installed.message": + "CLI sett upp í {{path}}\n\nEndurbyrja terminalin fyri at brúka skipanina 'opencode'.", + "desktop.cli.failed.title": "Innleggingin miseydnaðist", + "desktop.cli.failed.message": "Tað eydnaðist ikki at seta upp CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Rótarevni ikki funnið. Gloymdi tú at leggja tað til títt index.html? Ella kanska fekk id eginleikin skeivt stavað?", +} diff --git a/packages/desktop/src/renderer/i18n/fr.ts b/packages/desktop/src/renderer/i18n/fr.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fb53c6e26391379d3007449ffc5ae1d7a2646c8 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/fr.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Rechercher des mises à jour...", + "desktop.menu.installCli": "Installer l'interface en ligne de commande...", + "desktop.menu.reloadWebview": "Recharger la vue Web", + "desktop.menu.restart": "Redémarrer", + + "desktop.dialog.chooseFolder": "Choisir un dossier", + "desktop.dialog.chooseFile": "Choisir un fichier", + "desktop.dialog.saveFile": "Enregistrer le fichier", + + "desktop.updater.checkFailed.title": "Échec de la vérification des mises à jour", + "desktop.updater.checkFailed.message": "Impossible de vérifier les mises à jour", + "desktop.updater.none.title": "Aucune mise à jour disponible", + "desktop.updater.none.message": "Vous utilisez déjà la dernière version d'OpenCode", + "desktop.updater.downloadFailed.title": "Échec de la mise à jour", + "desktop.updater.downloadFailed.message": "Impossible de télécharger la mise à jour", + "desktop.updater.downloaded.title": "Mise à jour téléchargée", + "desktop.updater.downloaded.prompt": + "La version {{version}} d'OpenCode a été téléchargée. Voulez-vous l'installer et relancer l'application ?", + "desktop.updater.installFailed.title": "Échec de la mise à jour", + "desktop.updater.installFailed.message": "Impossible d'installer la mise à jour", + + "desktop.cli.installed.title": "Interface en ligne de commande installée", + "desktop.cli.installed.message": + "Interface en ligne de commande installée dans {{path}}\n\nRedémarrez votre terminal pour utiliser la commande 'opencode'.", + "desktop.cli.failed.title": "Échec de l'installation", + "desktop.cli.failed.message": "Impossible d'installer l'interface en ligne de commande : {{error}}", + + "desktop.error.dev.rootNotFound": + "Élément racine introuvable. Avez-vous oublié de l'ajouter à votre index.html ? Ou peut-être que l'attribut id est mal orthographié ?", +} diff --git a/packages/desktop/src/renderer/i18n/hi.ts b/packages/desktop/src/renderer/i18n/hi.ts new file mode 100644 index 0000000000000000000000000000000000000000..a549aa7cb610ce1ff219a08fd93fbff6c288a041 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/hi.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "अद्यतन के लिए जाँच...", + "desktop.menu.installCli": "CLI स्थापित करें...", + "desktop.menu.reloadWebview": "Webview पुनः लोड करें", + "desktop.menu.restart": "पुनः आरंभ करें", + "desktop.dialog.chooseFolder": "एक फ़ोल्डर चुनें", + "desktop.dialog.chooseFile": "एक फ़ाइल चुनें", + "desktop.dialog.saveFile": "फ़ाइल सहेजें", + "desktop.updater.checkFailed.title": "अद्यतन जांच विफल", + "desktop.updater.checkFailed.message": "अद्यतनों की जाँच करने में विफल", + "desktop.updater.none.title": "कोई अपडेट उपलब्ध नहीं", + "desktop.updater.none.message": "आप पहले से ही OpenCode का नवीनतम संस्करण उपयोग कर रहे हैं", + "desktop.updater.downloadFailed.title": "अपडेट विफल", + "desktop.updater.downloadFailed.message": "अद्यतन डाउनलोड करने में विफल", + "desktop.updater.downloaded.title": "अद्यतन डाउनलोड किया गया", + "desktop.updater.downloaded.prompt": + "OpenCode का संस्करण {{version}} डाउनलोड हो गया है। क्या आप इसे इंस्टॉल करके ऐप को फिर से खोलना चाहेंगे?", + "desktop.updater.installFailed.title": "अपडेट विफल", + "desktop.updater.installFailed.message": "अद्यतन स्थापित करने में विफल", + "desktop.cli.installed.title": "CLI स्थापित", + "desktop.cli.installed.message": + "CLI को {{path}} पर इंस्टॉल किया गया\n\n'opencode' कमांड का उपयोग करने के लिए अपना टर्मिनल पुनः आरंभ करें।", + "desktop.cli.failed.title": "स्थापना विफल", + "desktop.cli.failed.message": "CLI इंस्टॉल करने में विफल: {{error}}", + + "desktop.error.dev.rootNotFound": + "मूल तत्व नहीं मिला. क्या आप इसे अपने index.html में जोड़ना भूल गए? या हो सकता है कि आईडी विशेषता गलत वर्तनी हो गई हो?", +} diff --git a/packages/desktop/src/renderer/i18n/hr.ts b/packages/desktop/src/renderer/i18n/hr.ts new file mode 100644 index 0000000000000000000000000000000000000000..46977a57103e08f181afc4b013bb39ac5e130a9a --- /dev/null +++ b/packages/desktop/src/renderer/i18n/hr.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Provjeri ažuriranja...", + "desktop.menu.installCli": "Instaliraj CLI...", + "desktop.menu.reloadWebview": "Ponovno učitaj Webview", + "desktop.menu.restart": "Ponovno pokreni", + "desktop.dialog.chooseFolder": "Odaberite mapu", + "desktop.dialog.chooseFile": "Odaberite datoteku", + "desktop.dialog.saveFile": "Spremi datoteku", + "desktop.updater.checkFailed.title": "Provjera ažuriranja nije uspjela", + "desktop.updater.checkFailed.message": "Provjera ažuriranja nije uspjela", + "desktop.updater.none.title": "Nema dostupnih ažuriranja", + "desktop.updater.none.message": "Već koristite najnoviju verziju OpenCode", + "desktop.updater.downloadFailed.title": "Ažuriranje nije uspjelo", + "desktop.updater.downloadFailed.message": "Preuzimanje ažuriranja nije uspjelo", + "desktop.updater.downloaded.title": "Ažuriranje preuzeto", + "desktop.updater.downloaded.prompt": + "Preuzeta je verzija {{version}} aplikacije OpenCode. Želite li je instalirati i ponovno pokrenuti aplikaciju?", + "desktop.updater.installFailed.title": "Ažuriranje nije uspjelo", + "desktop.updater.installFailed.message": "Instalacija ažuriranja nije uspjela", + "desktop.cli.installed.title": "CLI je instaliran", + "desktop.cli.installed.message": + "CLI instaliran na {{path}}\n\nPonovno pokrenite terminal da biste koristili naredbu 'opencode'.", + "desktop.cli.failed.title": "Instalacija nije uspjela", + "desktop.cli.failed.message": "Instalacija CLI nije uspjela: {{error}}", + + "desktop.error.dev.rootNotFound": + "Korijenski element nije pronađen. Jeste li ga zaboravili dodati u svoj index.html? Ili je možda atribut id-a pogrešno napisan?", +} diff --git a/packages/desktop/src/renderer/i18n/hu.ts b/packages/desktop/src/renderer/i18n/hu.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b74a9726807e5ad232f302e69ec5be6104a957d --- /dev/null +++ b/packages/desktop/src/renderer/i18n/hu.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Frissítések keresése...", + "desktop.menu.installCli": "CLI telepítése...", + "desktop.menu.reloadWebview": "A Webview újratöltése", + "desktop.menu.restart": "Újraindítás", + "desktop.dialog.chooseFolder": "Válasszon egy mappát", + "desktop.dialog.chooseFile": "Válasszon egy fájlt", + "desktop.dialog.saveFile": "Fájl mentése", + "desktop.updater.checkFailed.title": "Frissítés ellenőrzése sikertelen", + "desktop.updater.checkFailed.message": "Nem sikerült ellenőrizni a frissítéseket", + "desktop.updater.none.title": "Nem érhető el frissítés", + "desktop.updater.none.message": "Már az OpenCode legújabb verzióját használja", + "desktop.updater.downloadFailed.title": "Frissítés sikertelen", + "desktop.updater.downloadFailed.message": "Nem sikerült letölteni a frissítést", + "desktop.updater.downloaded.title": "Frissítés letöltve", + "desktop.updater.downloaded.prompt": + "Az OpenCode {{version}} verziója letöltődött. Szeretné telepíteni és újraindítani az alkalmazást?", + "desktop.updater.installFailed.title": "Frissítés sikertelen", + "desktop.updater.installFailed.message": "Nem sikerült telepíteni a frissítést", + "desktop.cli.installed.title": "CLI telepítve", + "desktop.cli.installed.message": + "A CLI telepítési helye: {{path}}\n\nIndítsa újra a terminált az 'opencode' parancs használatához.", + "desktop.cli.failed.title": "A telepítés sikertelen", + "desktop.cli.failed.message": "A CLI telepítése sikertelen: {{error}}", + + "desktop.error.dev.rootNotFound": + "A gyökérelem nem található. Elfelejtette hozzáadni az index.html-hez? Vagy lehet, hogy az id attribútumot rosszul írták?", +} diff --git a/packages/desktop/src/renderer/i18n/it.ts b/packages/desktop/src/renderer/i18n/it.ts new file mode 100644 index 0000000000000000000000000000000000000000..52f3a85e7657dba39bc8000658b5bd18ab8b82f9 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/it.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Controlla gli aggiornamenti...", + "desktop.menu.installCli": "Installa CLI...", + "desktop.menu.reloadWebview": "Ricarica la webview", + "desktop.menu.restart": "Riavvia", + "desktop.dialog.chooseFolder": "Scegli una cartella", + "desktop.dialog.chooseFile": "Scegli un file", + "desktop.dialog.saveFile": "Salva file", + "desktop.updater.checkFailed.title": "Controllo degli aggiornamenti non riuscito", + "desktop.updater.checkFailed.message": "Impossibile controllare gli aggiornamenti", + "desktop.updater.none.title": "Nessun aggiornamento disponibile", + "desktop.updater.none.message": "Stai già utilizzando l'ultima versione di OpenCode", + "desktop.updater.downloadFailed.title": "Aggiornamento non riuscito", + "desktop.updater.downloadFailed.message": "Impossibile scaricare l'aggiornamento", + "desktop.updater.downloaded.title": "Aggiornamento scaricato", + "desktop.updater.downloaded.prompt": + "La versione {{version}} di OpenCode è stata scaricata. Vuoi installarla e riavviare l'app?", + "desktop.updater.installFailed.title": "Aggiornamento non riuscito", + "desktop.updater.installFailed.message": "Impossibile installare l'aggiornamento", + "desktop.cli.installed.title": "CLI installata", + "desktop.cli.installed.message": + "CLI installata in {{path}}\n\nRiavvia il terminale per utilizzare il comando 'opencode'.", + "desktop.cli.failed.title": "Installazione non riuscita", + "desktop.cli.failed.message": "Impossibile installare la CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Elemento radice non trovato. Hai dimenticato di aggiungerlo al tuo index.html? O forse l'attributo id è stato scritto in modo errato?", +} diff --git a/packages/desktop/src/renderer/i18n/km.ts b/packages/desktop/src/renderer/i18n/km.ts new file mode 100644 index 0000000000000000000000000000000000000000..03db393b7c33d802fee37e794c6b7d46ac325276 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/km.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "ពិនិត្យមើលបច្ចុប្បន្នភាព...", + "desktop.menu.installCli": "ដំឡើង CLI...", + "desktop.menu.reloadWebview": "ផ្ទុក Webview ឡើងវិញ", + "desktop.menu.restart": "ចាប់ផ្ដើមឡើងវិញ", + "desktop.dialog.chooseFolder": "ជ្រើសរើសថតឯកសារ", + "desktop.dialog.chooseFile": "ជ្រើសរើសឯកសារ", + "desktop.dialog.saveFile": "រក្សាទុកឯកសារ", + "desktop.updater.checkFailed.title": "ធីកអាប់ដេតបានបរាជ័យ", + "desktop.updater.checkFailed.message": "បានបរាជ័យក្នុងការពិនិត្យរកមើលបច្ចុប្បន្នភាព", + "desktop.updater.none.title": "មិនមានការអាប់ដេតទេ។", + "desktop.updater.none.message": "អ្នកកំពុងប្រើកំណែចុងក្រោយនៃ OpenCode រួចហើយ", + "desktop.updater.downloadFailed.title": "បរាជ័យក្នុងការអាប់ដេត", + "desktop.updater.downloadFailed.message": "បានបរាជ័យក្នុងការទាញយកបច្ចុប្បន្នភាព", + "desktop.updater.downloaded.title": "បានទាញយកបច្ចុប្បន្នភាព", + "desktop.updater.downloaded.prompt": + "កំណែ {{version}} នៃ OpenCode ត្រូវបានទាញយក តើអ្នកចង់ដំឡើងវា ហើយចាប់ផ្ដើមឡើងវិញទេ?", + "desktop.updater.installFailed.title": "ការអាប់ដេតបានបរាជ័យ", + "desktop.updater.installFailed.message": "បានបរាជ័យក្នុងការដំឡើងបច្ចុប្បន្នភាព", + "desktop.cli.installed.title": "CLI បានដំឡើង", + "desktop.cli.installed.message": + "CLI បានដំឡើងទៅ {{path}}\n\nចាប់ផ្តើមស្ថានីយរបស់អ្នកឡើងវិញ ដើម្បីប្រើពាក្យបញ្ជា 'opencode' ។", + "desktop.cli.failed.title": "ការដំឡើងបរាជ័យ", + "desktop.cli.failed.message": "បរាជ័យក្នុងការដំឡើង CLI៖ {{error}}", + + "desktop.error.dev.rootNotFound": + "រកមិនឃើញធាតុឫសទេ។ តើអ្នកភ្លេចបន្ថែមវាទៅ index.html របស់អ្នកទេ? ឬប្រហែលជាគុណលក្ខណៈលេខសម្គាល់ត្រូវបានសរសេរខុស?", +} diff --git a/packages/desktop/src/renderer/i18n/lo.ts b/packages/desktop/src/renderer/i18n/lo.ts new file mode 100644 index 0000000000000000000000000000000000000000..24625acf619ca05e4855cb3be744915633fd3c89 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/lo.ts @@ -0,0 +1,27 @@ +export const dict = { + "desktop.menu.checkForUpdates": "ກວດເບິ່ງການອັບເດດ...", + "desktop.menu.installCli": "ຕິດຕັ້ງ CLI...", + "desktop.menu.reloadWebview": "ໂຫຼດ Webview ຄືນໃໝ່", + "desktop.menu.restart": "ຣີສະຕາດ", + "desktop.dialog.chooseFolder": "ເລືອກໂຟນເດີ", + "desktop.dialog.chooseFile": "ເລືອກໄຟລ໌", + "desktop.dialog.saveFile": "ບັນທຶກໄຟລ໌", + "desktop.updater.checkFailed.title": "ກວດສອບການອັບເດດບໍ່ສຳເລັດ", + "desktop.updater.checkFailed.message": "ລົ້ມເຫລວໃນການກວດສອບການອັບເດດ", + "desktop.updater.none.title": "ບໍ່ມີການອັບເດດ", + "desktop.updater.none.message": "ທ່ານກຳລັງໃຊ້ OpenCode ເວີຊັນຫຼ້າສຸດຢູ່ແລ້ວ", + "desktop.updater.downloadFailed.title": "ການອັບເດດລົ້ມເຫລວ", + "desktop.updater.downloadFailed.message": "ລົ້ມເຫລວໃນການດາວໂຫຼດອັບເດດ", + "desktop.updater.downloaded.title": "ດາວໂຫຼດອັບເດດແລ້ວ", + "desktop.updater.downloaded.prompt": + "ເວີຊັນ {{version}} ຂອງ OpenCode ໄດ້ຖືກດາວໂຫຼດແລ້ວ, ທ່ານຕ້ອງການຕິດຕັ້ງມັນ ແລະເປີດຄືນໃໝ່ບໍ?", + "desktop.updater.installFailed.title": "ການອັບເດດລົ້ມເຫລວ", + "desktop.updater.installFailed.message": "ລົ້ມເຫລວໃນການຕິດຕັ້ງອັບເດດ", + "desktop.cli.installed.title": "CLI ຕິດຕັ້ງ", + "desktop.cli.installed.message": "ຕິດຕັ້ງ CLI ໄວ້ທີ່ {{path}}\n\nຣີສະຕາດເທີມິນອນເພື່ອໃຊ້ຄຳສັ່ງ 'opencode'.", + "desktop.cli.failed.title": "ການຕິດຕັ້ງລົ້ມເຫລວ", + "desktop.cli.failed.message": "ລົ້ມເຫລວໃນການຕິດຕັ້ງ CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "ບໍ່ພົບອົງປະກອບຮາກ. ທ່ານລືມເພີ່ມມັນໃສ່ index.html ຂອງທ່ານບໍ? ຫຼືບາງທີຄຸນສົມບັດ id ມີການສະກົດຜິດ?", +} diff --git a/packages/desktop/src/renderer/i18n/lt.ts b/packages/desktop/src/renderer/i18n/lt.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2c00b1e72ba4c9ef6f2ba0e771b80d9c9eeca86 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/lt.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Tikrinti, ar yra naujinimų...", + "desktop.menu.installCli": "Įdiegti CLI...", + "desktop.menu.reloadWebview": "Iš naujo įkelti Webview", + "desktop.menu.restart": "Paleisti iš naujo", + "desktop.dialog.chooseFolder": "Pasirinkite aplanką", + "desktop.dialog.chooseFile": "Pasirinkite failą", + "desktop.dialog.saveFile": "Išsaugoti failą", + "desktop.updater.checkFailed.title": "Naujinių patikrinti nepavyko", + "desktop.updater.checkFailed.message": "Nepavyko patikrinti, ar nėra naujinimų", + "desktop.updater.none.title": "Naujinių nėra", + "desktop.updater.none.message": "Jau naudojate naujausią OpenCode versiją", + "desktop.updater.downloadFailed.title": "Nepavyko atnaujinti", + "desktop.updater.downloadFailed.message": "Nepavyko atsisiųsti naujinimo", + "desktop.updater.downloaded.title": "Naujinimas parsiųstas", + "desktop.updater.downloaded.prompt": + "OpenCode versija {{version}} atsisiųsta, ar norėtumėte ją įdiegti ir paleisti iš naujo?", + "desktop.updater.installFailed.title": "Nepavyko atnaujinti", + "desktop.updater.installFailed.message": "Nepavyko įdiegti naujinimo", + "desktop.cli.installed.title": "CLI įdiegtas", + "desktop.cli.installed.message": + "CLI įdiegtas į {{path}}\n\nIš naujo paleiskite terminalą, kad galėtumėte naudoti komandą 'opencode'.", + "desktop.cli.failed.title": "Diegimas nepavyko", + "desktop.cli.failed.message": "Nepavyko įdiegti CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Šakninis elementas nerastas. Ar pamiršote jį įtraukti į index.html? O gal id atributas buvo neteisingai parašytas?", +} diff --git a/packages/desktop/src/renderer/i18n/lv.ts b/packages/desktop/src/renderer/i18n/lv.ts new file mode 100644 index 0000000000000000000000000000000000000000..00e6fece1475648e119f3b9aa2e963e780362d51 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/lv.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Pārbaudīt atjauninājumus...", + "desktop.menu.installCli": "Instalēt CLI...", + "desktop.menu.reloadWebview": "Pārlādēt tīmekļa skatu", + "desktop.menu.restart": "Restartēt", + "desktop.dialog.chooseFolder": "Izvēlieties mapi", + "desktop.dialog.chooseFile": "Izvēlieties failu", + "desktop.dialog.saveFile": "Saglabāt failu", + "desktop.updater.checkFailed.title": "Atjauninājumu pārbaude neizdevās", + "desktop.updater.checkFailed.message": "Neizdevās pārbaudīt atjauninājumus", + "desktop.updater.none.title": "Atjauninājumu nav", + "desktop.updater.none.message": "Jūs jau izmantojat jaunāko OpenCode versiju", + "desktop.updater.downloadFailed.title": "Atjaunināšana neizdevās", + "desktop.updater.downloadFailed.message": "Neizdevās lejupielādēt atjauninājumu", + "desktop.updater.downloaded.title": "Atjauninājums lejupielādēts", + "desktop.updater.downloaded.prompt": + "OpenCode versija {{version}} ir lejupielādēta. Vai vēlaties to instalēt un palaist no jauna?", + "desktop.updater.installFailed.title": "Atjaunināšana neizdevās", + "desktop.updater.installFailed.message": "Neizdevās instalēt atjauninājumu", + "desktop.cli.installed.title": "CLI instalēts", + "desktop.cli.installed.message": + "CLI instalēts uz {{path}}\n\nRestartējiet termināli, lai izmantotu komandu 'opencode'.", + "desktop.cli.failed.title": "Instalēšana neizdevās", + "desktop.cli.failed.message": "Neizdevās instalēt CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Saknes elements nav atrasts. Vai aizmirsāt to pievienot index.html? Vai arī id atribūts ir kļūdaini uzrakstīts?", +} diff --git a/packages/desktop/src/renderer/i18n/mk.ts b/packages/desktop/src/renderer/i18n/mk.ts new file mode 100644 index 0000000000000000000000000000000000000000..c85baafdc1dc9f4cb1646369b21a6474e9bfe4cf --- /dev/null +++ b/packages/desktop/src/renderer/i18n/mk.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Проверете дали има ажурирања...", + "desktop.menu.installCli": "Инсталирајте CLI...", + "desktop.menu.reloadWebview": "Вчитај повторно Webview", + "desktop.menu.restart": "Рестартирајте", + "desktop.dialog.chooseFolder": "Изберете папка", + "desktop.dialog.chooseFile": "Изберете датотека", + "desktop.dialog.saveFile": "Зачувај датотека", + "desktop.updater.checkFailed.title": "Проверката за ажурирање не успеа", + "desktop.updater.checkFailed.message": "Не успеа да се провери дали има ажурирања", + "desktop.updater.none.title": "Нема достапно ажурирање", + "desktop.updater.none.message": "Веќе ја користите најновата верзија на OpenCode", + "desktop.updater.downloadFailed.title": "Ажурирањето не успеа", + "desktop.updater.downloadFailed.message": "Не успеа да се преземе ажурирањето", + "desktop.updater.downloaded.title": "Ажурирањето е преземено", + "desktop.updater.downloaded.prompt": + "Верзијата {{version}} од OpenCode е преземена, дали сакате да ја инсталирате и повторно да ја стартувате?", + "desktop.updater.installFailed.title": "Ажурирањето не успеа", + "desktop.updater.installFailed.message": "Не успеа да се инсталира ажурирањето", + "desktop.cli.installed.title": "CLI Инсталиран", + "desktop.cli.installed.message": + "CLI инсталиран на {{path}}\n\nРестартирајте го терминалот за да ја користите командата „opencode“.", + "desktop.cli.failed.title": "Инсталирањето не успеа", + "desktop.cli.failed.message": "Не успеа да се инсталира CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Не е пронајден корен елемент. Дали заборавивте да го додадете во вашата index.html? Или можеби атрибутот id е погрешно напишан?", +} diff --git a/packages/desktop/src/renderer/i18n/mn.ts b/packages/desktop/src/renderer/i18n/mn.ts new file mode 100644 index 0000000000000000000000000000000000000000..020f70d9dab3b5a6a08c792397a1c664be88b608 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/mn.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Шинэчлэлтүүдийг шалгана уу...", + "desktop.menu.installCli": "Суулгах CLI...", + "desktop.menu.reloadWebview": "Дахин ачаалах Webview", + "desktop.menu.restart": "Дахин эхлүүлэх", + "desktop.dialog.chooseFolder": "Фолдер сонгоно уу", + "desktop.dialog.chooseFile": "Файл сонгоно уу", + "desktop.dialog.saveFile": "Файлыг хадгалах", + "desktop.updater.checkFailed.title": "Шинэчлэх шалгалт амжилтгүй боллоо", + "desktop.updater.checkFailed.message": "Шинэчлэлтүүдийг шалгаж чадсангүй", + "desktop.updater.none.title": "Шинэчлэлт байхгүй", + "desktop.updater.none.message": "Та OpenCode-н хамгийн сүүлийн хувилбарыг аль хэдийн ашиглаж байна", + "desktop.updater.downloadFailed.title": "Шинэчилж чадсангүй", + "desktop.updater.downloadFailed.message": "Шинэчлэлтийг татаж авч чадсангүй", + "desktop.updater.downloaded.title": "Шинэчлэлтийг татаж авсан", + "desktop.updater.downloaded.prompt": + "OpenCode-ийн {{version}} хувилбарыг татаж авсан тул та үүнийг суулгаад дахин эхлүүлэхийг хүсэж байна уу?", + "desktop.updater.installFailed.title": "Шинэчилж чадсангүй", + "desktop.updater.installFailed.message": "Шинэчлэлтийг суулгаж чадсангүй", + "desktop.cli.installed.title": "CLI Суулгасан", + "desktop.cli.installed.message": + "CLI {{path}}-д суулгасан\n\n'opencode' командыг ашиглахын тулд терминалаа дахин эхлүүлнэ үү.", + "desktop.cli.failed.title": "Суулгалт амжилтгүй боллоо", + "desktop.cli.failed.message": "CLI суулгаж чадсангүй: {{error}}", + + "desktop.error.dev.rootNotFound": + "Үндэс элемент олдсонгүй. Та үүнийг index.html дээрээ нэмэхээ мартсан уу? Эсвэл id атрибутыг буруу бичсэн байж магадгүй юм уу?", +} diff --git a/packages/desktop/src/renderer/i18n/ms.ts b/packages/desktop/src/renderer/i18n/ms.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdb8a47c89670b9556c4ed1ce91deaf3fff428ea --- /dev/null +++ b/packages/desktop/src/renderer/i18n/ms.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Semak Kemas Kini...", + "desktop.menu.installCli": "Pasang CLI...", + "desktop.menu.reloadWebview": "Muat Semula Paparan Web", + "desktop.menu.restart": "Mulakan Semula", + "desktop.dialog.chooseFolder": "Pilih folder", + "desktop.dialog.chooseFile": "Pilih fail", + "desktop.dialog.saveFile": "Simpan fail", + "desktop.updater.checkFailed.title": "Semakan Kemas Kini Gagal", + "desktop.updater.checkFailed.message": "Gagal menyemak kemas kini", + "desktop.updater.none.title": "Tiada Kemas Kini Tersedia", + "desktop.updater.none.message": "Anda sudah menggunakan versi terkini OpenCode", + "desktop.updater.downloadFailed.title": "Kemas Kini Gagal", + "desktop.updater.downloadFailed.message": "Gagal memuat turun kemas kini", + "desktop.updater.downloaded.title": "Kemas Kini Dimuat Turun", + "desktop.updater.downloaded.prompt": + "Versi {{version}} OpenCode telah dimuat turun, adakah anda ingin memasangnya dan melancarkan semula?", + "desktop.updater.installFailed.title": "Kemas Kini Gagal", + "desktop.updater.installFailed.message": "Gagal memasang kemas kini", + "desktop.cli.installed.title": "CLI Dipasang", + "desktop.cli.installed.message": + "CLI telah dipasang ke {{path}}\n\nMulakan semula terminal anda untuk menggunakan arahan 'opencode'.", + "desktop.cli.failed.title": "Pemasangan Gagal", + "desktop.cli.failed.message": "Gagal memasang CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Elemen root tidak ditemui. Adakah anda terlupa menambahnya ke index.html anda? Atau mungkin atribut id telah tersalah eja?", +} diff --git a/packages/desktop/src/renderer/i18n/nl.ts b/packages/desktop/src/renderer/i18n/nl.ts new file mode 100644 index 0000000000000000000000000000000000000000..c84faa03652a1d057b6088bac3dc659ac0662400 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/nl.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Controleren op updates...", + "desktop.menu.installCli": "CLI installeren...", + "desktop.menu.reloadWebview": "Webview opnieuw laden", + "desktop.menu.restart": "Opnieuw opstarten", + "desktop.dialog.chooseFolder": "Kies een map", + "desktop.dialog.chooseFile": "Kies een bestand", + "desktop.dialog.saveFile": "Bestand opslaan", + "desktop.updater.checkFailed.title": "Updatecontrole mislukt", + "desktop.updater.checkFailed.message": "Controleren op updates is mislukt", + "desktop.updater.none.title": "Geen update beschikbaar", + "desktop.updater.none.message": "Je gebruikt al de nieuwste versie van OpenCode", + "desktop.updater.downloadFailed.title": "Update mislukt", + "desktop.updater.downloadFailed.message": "Downloaden van update is mislukt", + "desktop.updater.downloaded.title": "Update gedownload", + "desktop.updater.downloaded.prompt": + "Versie {{version}} van OpenCode is gedownload. Wil je deze installeren en OpenCode opnieuw starten?", + "desktop.updater.installFailed.title": "Update mislukt", + "desktop.updater.installFailed.message": "Installeren van update is mislukt", + "desktop.cli.installed.title": "CLI geïnstalleerd", + "desktop.cli.installed.message": + "CLI geïnstalleerd op {{path}}\n\nStart je terminal opnieuw om de opdracht 'opencode' te gebruiken.", + "desktop.cli.failed.title": "Installatie mislukt", + "desktop.cli.failed.message": "Installeren van CLI is mislukt: {{error}}", + + "desktop.error.dev.rootNotFound": + "Root-element niet gevonden. Ben je vergeten het toe te voegen aan je index.html? Of is het id-attribuut misschien verkeerd gespeld?", +} diff --git a/packages/desktop/src/renderer/i18n/no.ts b/packages/desktop/src/renderer/i18n/no.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f52d70f0c1b786ce84de725accb760bfec3b399 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/no.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Se etter oppdateringer...", + "desktop.menu.installCli": "Installer CLI...", + "desktop.menu.reloadWebview": "Last inn Webview på nytt", + "desktop.menu.restart": "Start på nytt", + + "desktop.dialog.chooseFolder": "Velg en mappe", + "desktop.dialog.chooseFile": "Velg en fil", + "desktop.dialog.saveFile": "Lagre fil", + + "desktop.updater.checkFailed.title": "Oppdateringssjekk mislyktes", + "desktop.updater.checkFailed.message": "Kunne ikke se etter oppdateringer", + "desktop.updater.none.title": "Ingen oppdatering tilgjengelig", + "desktop.updater.none.message": "Du bruker allerede den nyeste versjonen av OpenCode", + "desktop.updater.downloadFailed.title": "Oppdatering mislyktes", + "desktop.updater.downloadFailed.message": "Kunne ikke laste ned oppdateringen", + "desktop.updater.downloaded.title": "Oppdatering lastet ned", + "desktop.updater.downloaded.prompt": + "Versjon {{version}} av OpenCode er lastet ned. Vil du installere den og starte på nytt?", + "desktop.updater.installFailed.title": "Oppdatering mislyktes", + "desktop.updater.installFailed.message": "Kunne ikke installere oppdateringen", + + "desktop.cli.installed.title": "CLI installert", + "desktop.cli.installed.message": + "CLI installert i {{path}}\n\nStart terminalen på nytt for å bruke 'opencode'-kommandoen.", + "desktop.cli.failed.title": "Installasjon mislyktes", + "desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributtet er feilstavet?", +} diff --git a/packages/desktop/src/renderer/i18n/pa.ts b/packages/desktop/src/renderer/i18n/pa.ts new file mode 100644 index 0000000000000000000000000000000000000000..9df4c082978ba9f467d9f4bf5ae1c8d388aa0d71 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/pa.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "اپ ڈیٹس لئی چیک کرو...", + "desktop.menu.installCli": "CLI انسٹال کرو...", + "desktop.menu.reloadWebview": "ویب ویو دوبارہ لوڈ کرو", + "desktop.menu.restart": "دوبارہ شروع کرو", + "desktop.dialog.chooseFolder": "اک فولڈر چنو", + "desktop.dialog.chooseFile": "اک فائل چنو", + "desktop.dialog.saveFile": "فائل محفوظ کرو", + "desktop.updater.checkFailed.title": "اپ ڈیٹ دی پڑتال ناکام ہو گئی", + "desktop.updater.checkFailed.message": "اپ ڈیٹاں دی پڑتال نئیں ہو سکی", + "desktop.updater.none.title": "کوئی اپ ڈیٹ دستیاب نئیں", + "desktop.updater.none.message": "تسی پہلے ای OpenCode دا تازہ ترین ورژن استعمال کر رئے او", + "desktop.updater.downloadFailed.title": "اپ ڈیٹ ناکام ہو گئی", + "desktop.updater.downloadFailed.message": "اپ ڈیٹ ڈاؤن لوڈ نئیں ہو سکی", + "desktop.updater.downloaded.title": "اپ ڈیٹ ڈاؤن لوڈ ہو گئی", + "desktop.updater.downloaded.prompt": + "OpenCode دا ورژن {{version}} ڈاؤن لوڈ کر دتا گیا اے، کی تسی اینوں انسٹال کرنا تے دوبارہ لانچ کرنا چاہندے او؟", + "desktop.updater.installFailed.title": "اپ ڈیٹ ناکام ہو گئی", + "desktop.updater.installFailed.message": "اپ ڈیٹ انسٹال نئیں ہو سکی", + "desktop.cli.installed.title": "CLI انسٹال ہو گیا", + "desktop.cli.installed.message": + "CLI {{path}} تے انسٹال کیتا گیا\n\n'opencode' کمانڈ ورتن لئی اپنے ٹرمینل نوں دوبارہ شروع کرو۔", + "desktop.cli.failed.title": "تنصیب ناکام ہو گئی", + "desktop.cli.failed.message": "CLI انسٹال نئیں ہو سکی: {{error}}", + + "desktop.error.dev.rootNotFound": + "عنصر نئیں لبیا۔ کی تسی ایہنوں اپنے index.html چ شامل کرنا بھل گئے او؟ یا شاید id وصف غلط ہجے ہو گیا اے؟", +} diff --git a/packages/desktop/src/renderer/i18n/pl.ts b/packages/desktop/src/renderer/i18n/pl.ts new file mode 100644 index 0000000000000000000000000000000000000000..320e0a869513256f4b118a829f56babc5dfaea03 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/pl.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Sprawdź aktualizacje...", + "desktop.menu.installCli": "Zainstaluj CLI...", + "desktop.menu.reloadWebview": "Załaduj ponownie WebView", + "desktop.menu.restart": "Uruchom ponownie", + + "desktop.dialog.chooseFolder": "Wybierz folder", + "desktop.dialog.chooseFile": "Wybierz plik", + "desktop.dialog.saveFile": "Zapisz plik", + + "desktop.updater.checkFailed.title": "Nie udało się sprawdzić aktualizacji", + "desktop.updater.checkFailed.message": "Nie udało się sprawdzić aktualizacji", + "desktop.updater.none.title": "Brak dostępnych aktualizacji", + "desktop.updater.none.message": "Korzystasz już z najnowszej wersji OpenCode", + "desktop.updater.downloadFailed.title": "Aktualizacja nie powiodła się", + "desktop.updater.downloadFailed.message": "Nie udało się pobrać aktualizacji", + "desktop.updater.downloaded.title": "Aktualizacja pobrana", + "desktop.updater.downloaded.prompt": + "Pobrano wersję {{version}} OpenCode. Czy chcesz ją zainstalować i uruchomić ponownie?", + "desktop.updater.installFailed.title": "Aktualizacja nie powiodła się", + "desktop.updater.installFailed.message": "Nie udało się zainstalować aktualizacji", + + "desktop.cli.installed.title": "Zainstalowano interfejs CLI", + "desktop.cli.installed.message": + "Interfejs CLI zainstalowano w {{path}}\n\nUruchom ponownie terminal, aby użyć polecenia „opencode”.", + "desktop.cli.failed.title": "Instalacja nie powiodła się", + "desktop.cli.failed.message": "Nie udało się zainstalować CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Nie znaleziono elementu głównego. Czy zapomniałeś dodać go do swojego index.html? A może atrybut id został błędnie wpisany?", +} diff --git a/packages/desktop/src/renderer/i18n/ro.ts b/packages/desktop/src/renderer/i18n/ro.ts new file mode 100644 index 0000000000000000000000000000000000000000..609a868778265cd47241b4a28e7510615cec10e9 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/ro.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Caută actualizări...", + "desktop.menu.installCli": "Instalează CLI...", + "desktop.menu.reloadWebview": "Reîncarcă webview", + "desktop.menu.restart": "Repornește", + "desktop.dialog.chooseFolder": "Alege un folder", + "desktop.dialog.chooseFile": "Alege un fișier", + "desktop.dialog.saveFile": "Salvează fișierul", + "desktop.updater.checkFailed.title": "Verificarea actualizărilor a eșuat", + "desktop.updater.checkFailed.message": "Nu s-au putut verifica actualizările", + "desktop.updater.none.title": "Nicio actualizare disponibilă", + "desktop.updater.none.message": "Folosești deja cea mai recentă versiune OpenCode", + "desktop.updater.downloadFailed.title": "Actualizarea a eșuat", + "desktop.updater.downloadFailed.message": "Nu s-a putut descărca actualizarea", + "desktop.updater.downloaded.title": "Actualizare descărcată", + "desktop.updater.downloaded.prompt": + "Versiunea {{version}} OpenCode a fost descărcată. Vrei să o instalezi și să repornești?", + "desktop.updater.installFailed.title": "Actualizarea a eșuat", + "desktop.updater.installFailed.message": "Nu s-a putut instala actualizarea", + "desktop.cli.installed.title": "CLI instalat", + "desktop.cli.installed.message": + "CLI a fost instalat în {{path}}\n\nRepornește terminalul pentru a folosi comanda 'opencode'.", + "desktop.cli.failed.title": "Instalarea a eșuat", + "desktop.cli.failed.message": "Nu s-a putut instala CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Elementul root nu a fost găsit. L-ai adăugat în index.html? Sau poate atributul id este scris greșit?", +} diff --git a/packages/desktop/src/renderer/i18n/ru.ts b/packages/desktop/src/renderer/i18n/ru.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9287505064251f45a55842d02c0cbdf3320217e --- /dev/null +++ b/packages/desktop/src/renderer/i18n/ru.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Проверить обновления...", + "desktop.menu.installCli": "Установить CLI...", + "desktop.menu.reloadWebview": "Перезагрузить WebView", + "desktop.menu.restart": "Перезапустить", + + "desktop.dialog.chooseFolder": "Выберите папку", + "desktop.dialog.chooseFile": "Выберите файл", + "desktop.dialog.saveFile": "Сохранить файл", + + "desktop.updater.checkFailed.title": "Не удалось проверить обновления", + "desktop.updater.checkFailed.message": "Не удалось проверить обновления", + "desktop.updater.none.title": "Обновлений нет", + "desktop.updater.none.message": "Вы уже используете последнюю версию OpenCode", + "desktop.updater.downloadFailed.title": "Обновление не удалось", + "desktop.updater.downloadFailed.message": "Не удалось скачать обновление", + "desktop.updater.downloaded.title": "Обновление загружено", + "desktop.updater.downloaded.prompt": "Версия OpenCode {{version}} загружена. Хотите установить и перезапустить?", + "desktop.updater.installFailed.title": "Обновление не удалось", + "desktop.updater.installFailed.message": "Не удалось установить обновление", + + "desktop.cli.installed.title": "CLI установлен", + "desktop.cli.installed.message": + "CLI установлен в {{path}}\n\nПерезапустите терминал, чтобы использовать команду 'opencode'.", + "desktop.cli.failed.title": "Ошибка установки", + "desktop.cli.failed.message": "Не удалось установить CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Корневой элемент не найден. Вы забыли добавить его в index.html? Или, может быть, атрибут id был написан неправильно?", +} diff --git a/packages/desktop/src/renderer/i18n/si.ts b/packages/desktop/src/renderer/i18n/si.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa3be873de7e48ec30ed2be5b4b2f955a50fbaa9 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/si.ts @@ -0,0 +1,27 @@ +export const dict: Record = { + "desktop.menu.checkForUpdates": "යාවත්කාලීන සඳහා පරීක්ෂා කරන්න...", + "desktop.menu.installCli": "CLI ස්ථාපනය කරන්න...", + "desktop.menu.reloadWebview": "Webview නැවත පූරණය කරන්න", + "desktop.menu.restart": "යළි අරඹන්න", + "desktop.dialog.chooseFolder": "ෆෝල්ඩරයක් තෝරන්න", + "desktop.dialog.chooseFile": "ගොනුවක් තෝරන්න", + "desktop.dialog.saveFile": "ගොනුව සුරකින්න", + "desktop.updater.checkFailed.title": "යාවත්කාලීන පරීක්ෂාව අසාර්ථක විය", + "desktop.updater.checkFailed.message": "යාවත්කාලීන සඳහා පරීක්ෂා කිරීමට අසමත් විය", + "desktop.updater.none.title": "යාවත්කාලීනයක් නොමැත", + "desktop.updater.none.message": "ඔබ දැනටමත් OpenCode හි නවතම අනුවාදය භාවිතා කරයි", + "desktop.updater.downloadFailed.title": "යාවත්කාලීන කිරීම අසාර්ථක විය", + "desktop.updater.downloadFailed.message": "යාවත්කාලීන බාගැනීම අසාර්ථක විය", + "desktop.updater.downloaded.title": "යාවත්කාලීනය බාගත කර ඇත", + "desktop.updater.downloaded.prompt": + "OpenCode හි {{version}} අනුවාදය බාගෙන ඇත, ඔබ එය ස්ථාපනය කර නැවත දියත් කිරීමට කැමතිද?", + "desktop.updater.installFailed.title": "යාවත්කාලීන කිරීම අසාර්ථක විය", + "desktop.updater.installFailed.message": "යාවත්කාලීන ස්ථාපනය කිරීමට අසමත් විය", + "desktop.cli.installed.title": "CLI ස්ථාපනය කර ඇත", + "desktop.cli.installed.message": "CLI {{path}}\n\n'opencode' විධානය භාවිතා කිරීමට ඔබගේ ටර්මිනලය නැවත ආරම්භ කරන්න.", + "desktop.cli.failed.title": "ස්ථාපනය අසාර්ථක විය", + "desktop.cli.failed.message": "CLI ස්ථාපනය කිරීමට අසමත් විය: {{error}}", + + "desktop.error.dev.rootNotFound": + "මූල මූලද්රව්යය හමු නොවීය. ඔබට එය ඔබගේ index.html වෙත එක් කිරීමට අමතකද? එසේත් නැතිනම් හැඳුනුම්පත වැරදි ලෙස සටහන් වී තිබේද?", +} diff --git a/packages/desktop/src/renderer/i18n/sk.ts b/packages/desktop/src/renderer/i18n/sk.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5b80e6058e0f94bd713b73c3115a6a49e382386 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/sk.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Skontrolovať aktualizácie...", + "desktop.menu.installCli": "Inštalovať CLI...", + "desktop.menu.reloadWebview": "Obnoviť webové zobrazenie", + "desktop.menu.restart": "Reštartovať", + "desktop.dialog.chooseFolder": "Vybrať priečinok", + "desktop.dialog.chooseFile": "Vybrať súbor", + "desktop.dialog.saveFile": "Uložiť súbor", + "desktop.updater.checkFailed.title": "Kontrola aktualizácií zlyhala", + "desktop.updater.checkFailed.message": "Nepodarilo sa skontrolovať aktualizácie", + "desktop.updater.none.title": "Žiadna aktualizácia nie je k dispozícii", + "desktop.updater.none.message": "Používate najnovšiu verziu OpenCode", + "desktop.updater.downloadFailed.title": "Aktualizácia zlyhala", + "desktop.updater.downloadFailed.message": "Nepodarilo sa stiahnuť aktualizáciu", + "desktop.updater.downloaded.title": "Aktualizácia stiahnutá", + "desktop.updater.downloaded.prompt": + "Verzia {{version}} OpenCode bola stiahnutá. Chcete ju nainštalovať a reštartovať?", + "desktop.updater.installFailed.title": "Aktualizácia zlyhala", + "desktop.updater.installFailed.message": "Nepodarilo sa nainštalovať aktualizáciu", + "desktop.cli.installed.title": "CLI nainštalované", + "desktop.cli.installed.message": + "CLI bolo nainštalované do {{path}}\n\nReštartujte terminál, aby ste mohli používať príkaz 'opencode'.", + "desktop.cli.failed.title": "Inštalácia zlyhala", + "desktop.cli.failed.message": "Nepodarilo sa nainštalovať CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Koreňový prvok sa nenašiel. Pridali ste ho do index.html? Alebo je atribút id napísaný nesprávne?", +} diff --git a/packages/desktop/src/renderer/i18n/sq.ts b/packages/desktop/src/renderer/i18n/sq.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed1679659ce4f1c044a9dbd2df2414ce207aed67 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/sq.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Kontrollo për përditësime...", + "desktop.menu.installCli": "Instalo CLI...", + "desktop.menu.reloadWebview": "Rifresko pamjen e internetit", + "desktop.menu.restart": "Rinis", + "desktop.dialog.chooseFolder": "Zgjidhni një dosje", + "desktop.dialog.chooseFile": "Zgjidhni një skedar", + "desktop.dialog.saveFile": "Ruaj skedarin", + "desktop.updater.checkFailed.title": "Kontrolli i përditësimit dështoi", + "desktop.updater.checkFailed.message": "Kontrolli për përditësime dështoi", + "desktop.updater.none.title": "Nuk ka përditësim të disponueshëm", + "desktop.updater.none.message": "Ju tashmë po përdorni versionin më të fundit të OpenCode", + "desktop.updater.downloadFailed.title": "Përditësimi dështoi", + "desktop.updater.downloadFailed.message": "Shkarkimi i përditësimit dështoi", + "desktop.updater.downloaded.title": "Përditësimi u shkarkua", + "desktop.updater.downloaded.prompt": + "Versioni {{version}} i OpenCode është shkarkuar, dëshironi ta instaloni dhe rinisni?", + "desktop.updater.installFailed.title": "Përditësimi dështoi", + "desktop.updater.installFailed.message": "Instalimi i përditësimit dështoi", + "desktop.cli.installed.title": "CLI i instaluar", + "desktop.cli.installed.message": + "CLI është instaluar në {{path}}\n\nRinisni terminalin tuaj për të përdorur komandën 'opencode'.", + "desktop.cli.failed.title": "Instalimi dështoi", + "desktop.cli.failed.message": "Instalimi i CLI dështoi: {{error}}", + + "desktop.error.dev.rootNotFound": + "Elementi rrënjë nuk u gjet. A keni harruar ta shtoni atë në index.html tuaj? Apo ndoshta atributi id është shkruar gabim?", +} diff --git a/packages/desktop/src/renderer/i18n/sr.ts b/packages/desktop/src/renderer/i18n/sr.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e5238bafbce61fb2fdcef117b03127d61129317 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/sr.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Проверите да ли постоје ажурирања...", + "desktop.menu.installCli": "Инсталирај CLI...", + "desktop.menu.reloadWebview": "Поново учитај Webview", + "desktop.menu.restart": "Поново покрените", + "desktop.dialog.chooseFolder": "Изаберите фасциклу", + "desktop.dialog.chooseFile": "Изаберите датотеку", + "desktop.dialog.saveFile": "Сачувај датотеку", + "desktop.updater.checkFailed.title": "Провера ажурирања није успела", + "desktop.updater.checkFailed.message": "Провера ажурирања није успела", + "desktop.updater.none.title": "Ажурирање није доступно", + "desktop.updater.none.message": "Већ користите најновију верзију OpenCode", + "desktop.updater.downloadFailed.title": "Ажурирање није успело", + "desktop.updater.downloadFailed.message": "Преузимање ажурирања није успело", + "desktop.updater.downloaded.title": "Преузето ажурирање", + "desktop.updater.downloaded.prompt": + "Верзија {{version}} од OpenCode је преузета, да ли желите да је инсталирате и поново покренете?", + "desktop.updater.installFailed.title": "Ажурирање није успело", + "desktop.updater.installFailed.message": "Инсталација ажурирања није успела", + "desktop.cli.installed.title": "CLI Инсталиран", + "desktop.cli.installed.message": + "CLI инсталиран на {{path}}\n\nПоново покрените терминал да бисте користили команду 'opencode'.", + "desktop.cli.failed.title": "Инсталација није успела", + "desktop.cli.failed.message": "Инсталација CLI-ја није успела: {{error}}", + + "desktop.error.dev.rootNotFound": + "Основни елемент није пронађен. Да ли сте заборавили да га додате у свој index.html? Или је можда атрибут ид погрешно написан?", +} diff --git a/packages/desktop/src/renderer/i18n/sv.ts b/packages/desktop/src/renderer/i18n/sv.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce269a3be9ee333ab56032f720ddaea5ea3a9aa3 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/sv.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Sök efter uppdateringar...", + "desktop.menu.installCli": "Installera CLI...", + "desktop.menu.reloadWebview": "Ladda om webbvyn", + "desktop.menu.restart": "Starta om", + "desktop.dialog.chooseFolder": "Välj en mapp", + "desktop.dialog.chooseFile": "Välj en fil", + "desktop.dialog.saveFile": "Spara filen", + "desktop.updater.checkFailed.title": "Uppdateringskontrollen misslyckades", + "desktop.updater.checkFailed.message": "Det gick inte att söka efter uppdateringar", + "desktop.updater.none.title": "Ingen uppdatering tillgänglig", + "desktop.updater.none.message": "Du använder redan den senaste versionen av OpenCode", + "desktop.updater.downloadFailed.title": "Uppdateringen misslyckades", + "desktop.updater.downloadFailed.message": "Det gick inte att ladda ned uppdateringen", + "desktop.updater.downloaded.title": "Uppdatering nedladdad", + "desktop.updater.downloaded.prompt": + "Version {{version}} av OpenCode har laddats ned. Vill du installera den och starta om programmet?", + "desktop.updater.installFailed.title": "Uppdateringen misslyckades", + "desktop.updater.installFailed.message": "Det gick inte att installera uppdateringen", + "desktop.cli.installed.title": "CLI installerat", + "desktop.cli.installed.message": + "CLI installerat i {{path}}\n\nStarta om terminalen för att använda kommandot 'opencode'.", + "desktop.cli.failed.title": "Installationen misslyckades", + "desktop.cli.failed.message": "Det gick inte att installera CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Rotelementet hittades inte. Har du glömt att lägga till det i din index.html? Eller kanske id-attributet är felstavat?", +} diff --git a/packages/desktop/src/renderer/i18n/th.ts b/packages/desktop/src/renderer/i18n/th.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b467503f4e3d0224421cc1cdb42631106dd4741 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/th.ts @@ -0,0 +1,29 @@ +export const dict = { + "desktop.menu.checkForUpdates": "ตรวจหาการอัปเดต...", + "desktop.menu.installCli": "ติดตั้ง CLI...", + "desktop.menu.reloadWebview": "โหลด Webview ใหม่", + "desktop.menu.restart": "เริ่มการทำงานใหม่", + + "desktop.dialog.chooseFolder": "เลือกโฟลเดอร์", + "desktop.dialog.chooseFile": "เลือกไฟล์", + "desktop.dialog.saveFile": "บันทึกไฟล์", + + "desktop.updater.checkFailed.title": "การตรวจหาการอัปเดตล้มเหลว", + "desktop.updater.checkFailed.message": "ไม่สามารถตรวจหาการอัปเดตได้", + "desktop.updater.none.title": "ไม่มีการอัปเดต", + "desktop.updater.none.message": "คุณกำลังใช้ OpenCode เวอร์ชันล่าสุดอยู่แล้ว", + "desktop.updater.downloadFailed.title": "การอัปเดตล้มเหลว", + "desktop.updater.downloadFailed.message": "ไม่สามารถดาวน์โหลดการอัปเดตได้", + "desktop.updater.downloaded.title": "ดาวน์โหลดการอัปเดตแล้ว", + "desktop.updater.downloaded.prompt": + "ดาวน์โหลด OpenCode เวอร์ชัน {{version}} แล้ว คุณต้องการติดตั้งและเปิดแอปอีกครั้งหรือไม่", + "desktop.updater.installFailed.title": "การอัปเดตล้มเหลว", + "desktop.updater.installFailed.message": "ไม่สามารถติดตั้งการอัปเดตได้", + + "desktop.cli.installed.title": "ติดตั้ง CLI แล้ว", + "desktop.cli.installed.message": "ติดตั้ง CLI ที่ {{path}} แล้ว\n\nเริ่มเทอร์มินัลใหม่เพื่อใช้คำสั่ง 'opencode'", + "desktop.cli.failed.title": "การติดตั้งล้มเหลว", + "desktop.cli.failed.message": "ไม่สามารถติดตั้ง CLI ได้: {{error}}", + + "desktop.error.dev.rootNotFound": "ไม่พบองค์ประกอบรูท คุณลืมเพิ่มใน index.html หรือบางทีแอตทริบิวต์ id อาจสะกดผิด?", +} diff --git a/packages/desktop/src/renderer/i18n/tk.ts b/packages/desktop/src/renderer/i18n/tk.ts new file mode 100644 index 0000000000000000000000000000000000000000..f53359db061fd18388bbfdd120fdffc66bba4498 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/tk.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Täzelenmeleri barlaň ...", + "desktop.menu.installCli": "CLI guruň ...", + "desktop.menu.reloadWebview": "Web sahypasyny täzeden ýükläň", + "desktop.menu.restart": "Gaýtadan açyň", + "desktop.dialog.chooseFolder": "Papka saýlaň", + "desktop.dialog.chooseFile": "Faýl saýlaň", + "desktop.dialog.saveFile": "Faýly ýazdyryň", + "desktop.updater.checkFailed.title": "Täzelenme şowsuz", + "desktop.updater.checkFailed.message": "Täzelenmeleri barlap bilmedi", + "desktop.updater.none.title": "Täzelenme ýok", + "desktop.updater.none.message": "OpenCode-iň iň soňky wersiýasyny eýýäm ulanýarsyňyz", + "desktop.updater.downloadFailed.title": "Täzelenme şowsuz", + "desktop.updater.downloadFailed.message": "Täzelenmäni göçürip alyp bilmedi", + "desktop.updater.downloaded.title": "Täzelenme ýüklendi", + "desktop.updater.downloaded.prompt": + "OpenCode-iň {{version}} wersiýasy göçürildi, ony gurup täzeden işletmek isleýärsiňizmi?", + "desktop.updater.installFailed.title": "Täzelenme şowsuz", + "desktop.updater.installFailed.message": "Täzelenmäni gurup bilmedi", + "desktop.cli.installed.title": "CLI Guruldy", + "desktop.cli.installed.message": + "CLI {{path}} salgysyna guruldy\n\n'opencode' buýrugyny ulanmak üçin terminalyňyzy täzeden açyň.", + "desktop.cli.failed.title": "Gurmak şowsuz", + "desktop.cli.failed.message": "CLI gurup bilmedi: {{error}}", + + "desktop.error.dev.rootNotFound": + "Kök elementi tapylmady index.html-e goşmagy ýatdan çykardyňyzmy? Ora-da id atributynyň ýalňyş ýazylan bolmagy mümkin?", +} diff --git a/packages/desktop/src/renderer/i18n/tr.ts b/packages/desktop/src/renderer/i18n/tr.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1b8d11f0e464f5ff310b884a36cc5516b803595 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/tr.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Güncellemeleri kontrol et...", + "desktop.menu.installCli": "CLI'yi yükle...", + "desktop.menu.reloadWebview": "Web görünümünü yeniden yükle", + "desktop.menu.restart": "Yeniden başlat", + + "desktop.dialog.chooseFolder": "Bir klasör seçin", + "desktop.dialog.chooseFile": "Bir dosya seçin", + "desktop.dialog.saveFile": "Dosyayı kaydedin", + + "desktop.updater.checkFailed.title": "Güncelleme kontrolü başarısız oldu", + "desktop.updater.checkFailed.message": "Güncellemeler kontrol edilemedi", + "desktop.updater.none.title": "Güncelleme yok", + "desktop.updater.none.message": "OpenCode'un en son sürümünü zaten kullanıyorsunuz", + "desktop.updater.downloadFailed.title": "Güncelleme başarısız oldu", + "desktop.updater.downloadFailed.message": "Güncelleme indirilemedi", + "desktop.updater.downloaded.title": "Güncelleme indirildi", + "desktop.updater.downloaded.prompt": + "OpenCode'un {{version}} sürümü indirildi. Şimdi yükleyip yeniden başlatmak ister misiniz?", + "desktop.updater.installFailed.title": "Güncelleme başarısız oldu", + "desktop.updater.installFailed.message": "Güncelleme yüklenemedi", + + "desktop.cli.installed.title": "CLI yüklendi", + "desktop.cli.installed.message": + "CLI {{path}} konumuna yüklendi\n\n'opencode' komutunu kullanmak için terminalinizi yeniden başlatın.", + "desktop.cli.failed.title": "Yükleme başarısız oldu", + "desktop.cli.failed.message": "CLI yüklenemedi: {{error}}", + + "desktop.error.dev.rootNotFound": + "Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?", +} diff --git a/packages/desktop/src/renderer/i18n/uk.ts b/packages/desktop/src/renderer/i18n/uk.ts new file mode 100644 index 0000000000000000000000000000000000000000..10117877ddd800c6774bf00508c04652c1c293cf --- /dev/null +++ b/packages/desktop/src/renderer/i18n/uk.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Перевірити оновлення...", + "desktop.menu.installCli": "Встановити CLI...", + "desktop.menu.reloadWebview": "Перезавантажити Webview", + "desktop.menu.restart": "Перезапустити", + + "desktop.dialog.chooseFolder": "Виберіть папку", + "desktop.dialog.chooseFile": "Виберіть файл", + "desktop.dialog.saveFile": "Зберегти файл", + + "desktop.updater.checkFailed.title": "Не вдалося перевірити оновлення", + "desktop.updater.checkFailed.message": "Не вдалося перевірити наявність оновлень", + "desktop.updater.none.title": "Немає доступних оновлень", + "desktop.updater.none.message": "Ви вже використовуєте найновішу версію OpenCode", + "desktop.updater.downloadFailed.title": "Помилка оновлення", + "desktop.updater.downloadFailed.message": "Не вдалося завантажити оновлення", + "desktop.updater.downloaded.title": "Оновлення завантажено", + "desktop.updater.downloaded.prompt": + "Версію {{version}} OpenCode завантажено. Бажаєте встановити її та перезапустити?", + "desktop.updater.installFailed.title": "Помилка оновлення", + "desktop.updater.installFailed.message": "Не вдалося встановити оновлення", + + "desktop.cli.installed.title": "CLI встановлено", + "desktop.cli.installed.message": + "CLI встановлено за шляхом {{path}}\n\nПерезапустіть термінал, щоб використовувати команду 'opencode'.", + "desktop.cli.failed.title": "Не вдалося встановити", + "desktop.cli.failed.message": "Не вдалося встановити CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?", +} diff --git a/packages/desktop/src/renderer/i18n/ur.ts b/packages/desktop/src/renderer/i18n/ur.ts new file mode 100644 index 0000000000000000000000000000000000000000..1606d1b0548925b1097d5041c9120b63f6cc27a3 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/ur.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "اپ ڈیٹس کے لیے چیک کریں...", + "desktop.menu.installCli": "CLI انسٹال کریں...", + "desktop.menu.reloadWebview": "ویب ویو کو دوبارہ لوڈ کریں۔", + "desktop.menu.restart": "دوبارہ شروع کریں۔", + "desktop.dialog.chooseFolder": "ایک فولڈر منتخب کریں۔", + "desktop.dialog.chooseFile": "ایک فائل کا انتخاب کریں۔", + "desktop.dialog.saveFile": "فائل کو محفوظ کریں۔", + "desktop.updater.checkFailed.title": "اپ ڈیٹ کی جانچ ناکام ہو گئی", + "desktop.updater.checkFailed.message": "اپ ڈیٹس چیک کرنے میں ناکام", + "desktop.updater.none.title": "کوئی اپ ڈیٹ دستیاب نہیں", + "desktop.updater.none.message": "آپ پہلے ہی OpenCode کا تازہ ترین ورژن استعمال کر رہے ہیں۔", + "desktop.updater.downloadFailed.title": "اپ ڈیٹ ناکام ہو گیا۔", + "desktop.updater.downloadFailed.message": "اپ ڈیٹ ڈاؤن لوڈ کرنے میں ناکام", + "desktop.updater.downloaded.title": "اپ ڈیٹ ڈاؤن لوڈ ہو گیا۔", + "desktop.updater.downloaded.prompt": + "OpenCode کا ورژن {{version}} ڈاؤن لوڈ ہو چکا ہے، کیا آپ اسے انسٹال کر کے دوبارہ لانچ کرنا چاہیں گے؟", + "desktop.updater.installFailed.title": "اپ ڈیٹ ناکام ہو گیا۔", + "desktop.updater.installFailed.message": "اپ ڈیٹ انسٹال کرنے میں ناکام", + "desktop.cli.installed.title": "CLI انسٹال ہو گئی", + "desktop.cli.installed.message": + "CLI کو {{path}} پر انسٹال کر دیا گیا۔\n\n'opencode' کمانڈ استعمال کرنے کے لیے اپنا ٹرمینل دوبارہ شروع کریں۔", + "desktop.cli.failed.title": "تنصیب ناکام ہو گئی۔", + "desktop.cli.failed.message": "CLI انسٹال کرنے میں ناکام: {{error}}", + + "desktop.error.dev.rootNotFound": + "روٹ عنصر نہیں ملا۔ کیا آپ اسے اپنے index.html میں شامل کرنا بھول گئے؟ یا ہو سکتا ہے کہ آئی ڈی وصف کی ہجے غلط ہو گئی ہو؟", +} diff --git a/packages/desktop/src/renderer/i18n/vi.ts b/packages/desktop/src/renderer/i18n/vi.ts new file mode 100644 index 0000000000000000000000000000000000000000..985552cbd3903835fb8336773111c25ed0e73925 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/vi.ts @@ -0,0 +1,27 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Kiểm tra cập nhật...", + "desktop.menu.installCli": "Cài đặt CLI...", + "desktop.menu.reloadWebview": "Tải lại Webview", + "desktop.menu.restart": "Khởi động lại", + "desktop.dialog.chooseFolder": "Chọn một thư mục", + "desktop.dialog.chooseFile": "Chọn một tệp", + "desktop.dialog.saveFile": "Lưu tệp", + "desktop.updater.checkFailed.title": "Kiểm tra cập nhật không thành công", + "desktop.updater.checkFailed.message": "Không thể kiểm tra cập nhật", + "desktop.updater.none.title": "Không có bản cập nhật nào", + "desktop.updater.none.message": "Bạn đang sử dụng phiên bản mới nhất của OpenCode", + "desktop.updater.downloadFailed.title": "Cập nhật không thành công", + "desktop.updater.downloadFailed.message": "Không tải được bản cập nhật xuống", + "desktop.updater.downloaded.title": "Đã tải xuống bản cập nhật", + "desktop.updater.downloaded.prompt": + "Phiên bản {{version}} của OpenCode đã được tải xuống, bạn có muốn cài đặt và khởi chạy lại không?", + "desktop.updater.installFailed.title": "Cập nhật không thành công", + "desktop.updater.installFailed.message": "Không cài đặt được bản cập nhật", + "desktop.cli.installed.title": "Đã cài đặt CLI", + "desktop.cli.installed.message": "Đã cài đặt CLI vào {{path}}\n\nKhởi động lại terminal để sử dụng lệnh 'opencode'.", + "desktop.cli.failed.title": "Cài đặt không thành công", + "desktop.cli.failed.message": "Không cài đặt được CLI: {{error}}", + + "desktop.error.dev.rootNotFound": + "Không tìm thấy phần tử gốc. Bạn đã quên thêm nó vào index.html của mình? Hoặc có thể thuộc tính id bị sai chính tả?", +} diff --git a/packages/desktop/src/renderer/i18n/zh.ts b/packages/desktop/src/renderer/i18n/zh.ts new file mode 100644 index 0000000000000000000000000000000000000000..d77e25417f7ed80d4bb1de8c5e8975b51a810f9d --- /dev/null +++ b/packages/desktop/src/renderer/i18n/zh.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "检查更新...", + "desktop.menu.installCli": "安装 CLI...", + "desktop.menu.reloadWebview": "重新加载 WebView", + "desktop.menu.restart": "重启", + + "desktop.dialog.chooseFolder": "选择文件夹", + "desktop.dialog.chooseFile": "选择文件", + "desktop.dialog.saveFile": "保存文件", + + "desktop.updater.checkFailed.title": "检查更新失败", + "desktop.updater.checkFailed.message": "无法检查更新", + "desktop.updater.none.title": "没有可用更新", + "desktop.updater.none.message": "你已经在使用最新版本的 OpenCode", + "desktop.updater.downloadFailed.title": "更新失败", + "desktop.updater.downloadFailed.message": "无法下载更新", + "desktop.updater.downloaded.title": "更新已下载", + "desktop.updater.downloaded.prompt": "OpenCode {{version}} 已下载。是否安装并重新启动?", + "desktop.updater.installFailed.title": "更新失败", + "desktop.updater.installFailed.message": "无法安装更新", + + "desktop.cli.installed.title": "CLI 已安装", + "desktop.cli.installed.message": "CLI 已安装到 {{path}}\n\n重启终端以使用 'opencode' 命令。", + "desktop.cli.failed.title": "安装失败", + "desktop.cli.failed.message": "无法安装 CLI:{{error}}", + + "desktop.error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?", +} diff --git a/packages/desktop/src/renderer/i18n/zht.ts b/packages/desktop/src/renderer/i18n/zht.ts new file mode 100644 index 0000000000000000000000000000000000000000..54d062140b79585de3e2f841bd8e3ba119bed1c3 --- /dev/null +++ b/packages/desktop/src/renderer/i18n/zht.ts @@ -0,0 +1,28 @@ +export const dict = { + "desktop.menu.checkForUpdates": "檢查更新...", + "desktop.menu.installCli": "安裝 CLI...", + "desktop.menu.reloadWebview": "重新載入 Webview", + "desktop.menu.restart": "重新啟動", + + "desktop.dialog.chooseFolder": "選擇資料夾", + "desktop.dialog.chooseFile": "選擇檔案", + "desktop.dialog.saveFile": "儲存檔案", + + "desktop.updater.checkFailed.title": "檢查更新失敗", + "desktop.updater.checkFailed.message": "無法檢查更新", + "desktop.updater.none.title": "沒有可用更新", + "desktop.updater.none.message": "你已在使用最新版的 OpenCode", + "desktop.updater.downloadFailed.title": "更新失敗", + "desktop.updater.downloadFailed.message": "無法下載更新", + "desktop.updater.downloaded.title": "更新已下載", + "desktop.updater.downloaded.prompt": "OpenCode {{version}} 已下載。要安裝並重新啟動嗎?", + "desktop.updater.installFailed.title": "更新失敗", + "desktop.updater.installFailed.message": "無法安裝更新", + + "desktop.cli.installed.title": "CLI 已安裝", + "desktop.cli.installed.message": "CLI 已安裝到 {{path}}\n\n重新啟動終端機即可使用 `opencode` 命令。", + "desktop.cli.failed.title": "安裝失敗", + "desktop.cli.failed.message": "無法安裝 CLI:{{error}}", + + "desktop.error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html?或者 id 屬性拼錯了?", +} diff --git a/packages/desktop/src/renderer/index.html b/packages/desktop/src/renderer/index.html new file mode 100644 index 0000000000000000000000000000000000000000..b7c429fb648a238b8e2ffa1576b50842a57f330f --- /dev/null +++ b/packages/desktop/src/renderer/index.html @@ -0,0 +1,21 @@ + + + + + + OpenCode + + + + + + + + + + + +
+ + + diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..496060e0d665b86c9933568ffaf99ceaa340149e --- /dev/null +++ b/packages/desktop/src/renderer/index.tsx @@ -0,0 +1,452 @@ +// @refresh reload + +import { + ACCEPTED_FILE_EXTENSIONS, + AppBaseProviders, + AppInterface, + loadLocaleDict, + normalizeLocale, + type Locale, + type Platform, + PlatformProvider, + createDraftStore, + ServerConnection, + useCommand, + useWslServers, + useLanguage, +} from "@opencode-ai/app" +import type { UpdaterState } from "@opencode-ai/app/updater" +import * as Sentry from "@sentry/solid" +import type { AsyncStorage } from "@solid-primitives/storage" +import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router" +import { createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js" +import { render } from "solid-js/web" +import pkg from "../../package.json" +import { t } from "./i18n" +import { initializationData } from "./initialization" +import { DesktopFirstLaunchOnboarding } from "./onboarding" +import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" +import { windowFullscreen } from "./window-fullscreen" +import { availableStartupServer, readyWslConnections } from "./wsl/connections" +import "./styles.css" +import { Splash } from "@opencode-ai/ui/logo" +import { useTheme } from "@opencode-ai/ui/theme/context" + +const root = document.getElementById("root") +if (import.meta.env.DEV && !(root instanceof HTMLElement)) { + throw new Error(t("desktop.error.dev.rootNotFound")) +} + +if (import.meta.env.VITE_SENTRY_DSN) { + Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${pkg.version}`, + initialScope: { + tags: { + platform: "desktop", + }, + }, + integrations: (integrations) => { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && + !( + import.meta.env.OPENCODE_CHANNEL === "prod" && + (i.name === "GlobalHandlers" || i.name === "BrowserApiErrors") + ), + ) + }, + }) +} + +const [updaterState, setUpdaterState] = createSignal({ status: "disabled" }) +void window.api.updater.subscribe(setUpdaterState) + +const deepLinkEvent = "opencode:deep-link" + +type DesktopWindowState = { + id?: string +} + +const emitDeepLinks = (urls: string[]) => { + if (urls.length === 0) return + window.__OPENCODE__ ??= {} + const pending = window.__OPENCODE__.deepLinks ?? [] + window.__OPENCODE__.deepLinks = [...pending, ...urls] + window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } })) +} + +const listenForDeepLinks = () => { + void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls)) + return window.api.onDeepLink((urls) => emitDeepLinks(urls)) +} + +function windowLastActiveUrlKey(windowID: string) { + return `opencode.desktop.window.${windowID}.last-active-url` +} + +function getLastActiveUrl(windowID: string) { + if (typeof localStorage !== "object") return "/" + try { + const value = localStorage.getItem(windowLastActiveUrlKey(windowID)) + if (value?.startsWith("/") && !value.startsWith("//")) return value + } catch {} + return "/" +} + +function setLastActiveUrl(windowID: string, value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(windowLastActiveUrlKey(windowID), value) + } catch {} +} + +function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) { + const history = createMemoryHistory() + const initialUrl = getLastActiveUrl(props.windowID) + if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false }) + onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value))) + return +} + +const createPlatform = (windowState: DesktopWindowState): Platform => { + const attachmentPaths = new WeakMap() + const os = (() => { + const ua = navigator.userAgent + if (ua.includes("Mac")) return "macos" + if (ua.includes("Windows")) return "windows" + if (ua.includes("Linux")) return "linux" + return undefined + })() + + const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => { + switch (action) { + case "view.resetZoom": + resetZoom() + return + case "view.zoomIn": + zoomIn() + return + case "view.zoomOut": + zoomOut() + return + } + + return window.api.runDesktopMenuAction(action) + } + + const storage = (() => { + const cache = new Map() + + const createStorage = (name: string) => { + const api: AsyncStorage = { + getItem: (key: string) => window.api.storeGet(name, key), + setItem: (key: string, value: string) => window.api.storeSet(name, key, value), + removeItem: (key: string) => window.api.storeDelete(name, key), + clear: () => window.api.storeClear(name), + key: async (index: number) => (await window.api.storeKeys(name))[index], + getLength: () => window.api.storeLength(name), + get length() { + return api.getLength() + }, + } + return api + } + + return (name = "default.dat") => { + const cached = cache.get(name) + if (cached) return cached + const api = createStorage(name) + cache.set(name, api) + return api + } + })() + + const wslServersApi = os === "windows" ? window.api.wslServers : undefined + + return { + platform: "desktop", + os, + version: pkg.version, + windowID: windowState.id, + + async openDirectoryPickerDialog(opts) { + return window.api.openDirectoryPicker({ + multiple: opts?.multiple ?? false, + title: opts?.title, + }) + }, + + async openAttachmentPickerDialog(opts, onFile) { + const result = await window.api.openFilePicker({ + multiple: opts?.multiple ?? false, + title: opts?.title, + defaultPath: opts?.defaultPath, + extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS, + }) + if (!result) return + try { + for (const file of result.files) { + const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name) + attachmentPaths.set(selected, file.path) + await onFile(selected) + } + } finally { + await window.api.releasePickedFiles(result.token) + } + }, + + getPathForFile(file) { + return attachmentPaths.get(file) ?? window.api.getPathForFile(file) + }, + + async saveFilePickerDialog(opts) { + return window.api.saveFilePicker({ + title: opts?.title, + defaultPath: opts?.defaultPath, + }) + }, + + openExternal(url: string) { + window.api.openExternal(url) + }, + openLocalFile(url: string) { + window.api.openLocalFile(url) + }, + async openPath(path: string, app?: string) { + if (os === "windows") { + const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null + return window.api.openPath(path, resolvedApp ?? undefined) + } + return window.api.openPath(path, app) + }, + async revealPath(path: string) { + return window.api.revealPath(path) + }, + + storage, + draftStore: createDraftStore({ + get: window.api.draftGet, + set: window.api.draftSet, + remove: window.api.draftDelete, + putBlob: (blob) => blob.arrayBuffer().then(window.api.draftBlobPut), + getBlob: (id) => window.api.draftBlobGet(id).then((data) => data && new Blob([data])), + }), + + updater: { + state: updaterState, + check: () => window.api.updater.check(), + install: () => window.api.updater.install(), + }, + + exportDebugLogs: () => window.api.exportDebugLogs(), + + setForceFocus: (enabled) => window.api.setForceFocus(enabled), + + recordFatalRendererError: (error) => window.api.recordFatalRendererError(error), + + restart: async () => { + await window.api.killSidecar().catch(() => undefined) + window.api.relaunch() + }, + + notify: async (title, description, onClick) => { + const focused = await window.api.getWindowFocused().catch(() => document.hasFocus()) + if (focused) return + + const notification = new Notification(title, { + body: description ?? "", + icon: "https://opencode.ai/favicon-96x96-v3.png", + }) + notification.onclick = () => { + void window.api.showWindow() + void window.api.setWindowFocus() + onClick?.() + notification.close() + } + }, + + fetch: (input, init) => { + if (input instanceof Request) return fetch(input) + return fetch(input, init) + }, + + getDefaultServer: async () => { + const url = await window.api.getDefaultServerUrl().catch(() => null) + if (!url) return null + return ServerConnection.Key.make(url) + }, + + setDefaultServer: async (url: string | null) => { + await window.api.setDefaultServerUrl(url) + }, + + wslServers: wslServersApi, + + getDisplayBackend: async () => { + return window.api.getDisplayBackend().catch(() => null) + }, + + setDisplayBackend: async (backend) => { + await window.api.setDisplayBackend(backend) + }, + + webviewZoom, + + windowFullscreen, + + getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(), + + setPinchZoomEnabled, + + runDesktopMenuAction, + + checkAppExists: async (appName: string) => { + return window.api.checkAppExists(appName) + }, + + async readClipboardImage() { + const image = await window.api.readClipboardImage().catch(() => null) + if (!image) return null + const blob = new Blob([image.buffer], { type: "image/png" }) + return new File([blob], `pasted-image-${Date.now()}.png`, { + type: "image/png", + }) + }, + } +} + +let menuTrigger = null as null | ((id: string) => void) +window.api.onMenuCommand((id) => { + menuTrigger?.(id) +}) +listenForDeepLinks() + +function LoadingSplash() { + return ( +
+ +
+ ) +} + +function DesktopRoot(props: { windowState: DesktopWindowState }) { + const platform = createPlatform(props.windowState) + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } + + // Fetch sidecar credentials (available immediately, before health check) + const [sidecar] = createResource(() => window.api.awaitInitialization()) + + const [defaultServer] = createResource(() => platform.getDefaultServer?.()) + const [locale] = createResource(loadLocale) + const router = (props: BaseRouterProps) => ( + + ) + const onboarding = Promise.withResolvers() + + function Inner() { + const cmd = useCommand() + menuTrigger = (id) => cmd.trigger(id) + + const theme = useTheme() + + createEffect(() => { + theme.themeId() + theme.mode() + const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim() + if (bg) { + void window.api.setBackgroundColor(bg) + } + }) + + return null + } + + function App() { + const wslServers = useWslServers() + const language = useLanguage() + const ready = createMemo( + () => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading, + ) + const servers = createMemo(() => { + const data = initializationData(sidecar) + const list: ServerConnection.Any[] = [] + if (data) { + list.push({ + displayName: language.t("desktop.server.local"), + type: "sidecar", + variant: "base", + http: { + url: data.url, + username: data.username ?? undefined, + password: data.password ?? undefined, + }, + }) + } + list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label"))) + return list + }) + const effectiveDefaultServer = createMemo(() => + ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)), + ) + return ( + }> + + {(key) => ( + + } + > + + + )} + + + ) + } + + return ( + + void window.api.setNativeTranslations(bundle).catch(() => undefined)} + > + {(_) => } + + + ) +} + +render(() => { + const [windowState] = createResource(async () => { + const api = window.api as typeof window.api & { + getWindowID?: () => Promise + } + return { id: await api.getWindowID?.() } + }) + + return ( + } keyed> + {(state) => } + + ) +}, root!) diff --git a/packages/desktop/src/renderer/initialization.test.ts b/packages/desktop/src/renderer/initialization.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..30eaebd6802a6964e9c5eeb73476e5a8ccb57170 --- /dev/null +++ b/packages/desktop/src/renderer/initialization.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { initializationData, initializationReady } from "./initialization" + +describe("desktop renderer initialization", () => { + test("throws the original initialization error before rendering server providers", () => { + const error = new Error("sidecar startup failed") + + try { + initializationData(Object.assign(() => undefined, { error })) + throw new Error("expected initialization to fail") + } catch (failure) { + expect(failure).toBe(error) + expect((failure as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true) + } + }) + + test("removes Electron's remote invocation wrapper from startup errors", () => { + const error = new Error( + "Error invoking remote method 'await-initialization': Error: Cannot migrate session_message projections", + ) + + try { + initializationData(Object.assign(() => undefined, { error })) + throw new Error("expected initialization to fail") + } catch (failure) { + expect(failure).toBe(error) + expect((failure as Error).message).toBe("Cannot migrate session_message projections") + } + }) + + test("returns initialized sidecar data", () => { + const sidecar = { url: "http://127.0.0.1:1234", username: "opencode", password: "secret" } + + expect(initializationData(Object.assign(() => sidecar, { error: undefined }))).toBe(sidecar) + }) + + test("does not discard falsy initialization errors", () => { + let caught: unknown + try { + initializationData(Object.assign(() => undefined, { error: "" })) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + if (!(caught instanceof Error)) return + expect(caught.message).toBe("") + expect((caught as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true) + }) + + test("checks initialization errors before rendering server providers", () => { + const error = new Error("sidecar startup failed") + + expect(() => initializationReady(Object.assign(() => undefined, { error, loading: false }))).toThrow(error) + }) + + test("waits for pending initialization without reading it", () => { + let reads = 0 + + expect( + initializationReady( + Object.assign( + () => { + reads++ + return undefined + }, + { error: undefined, loading: true }, + ), + ), + ).toBe(false) + expect(reads).toBe(0) + }) +}) diff --git a/packages/desktop/src/renderer/initialization.ts b/packages/desktop/src/renderer/initialization.ts new file mode 100644 index 0000000000000000000000000000000000000000..b68eae09cb13b9bd366ff0b22ff73d98bee0a3c3 --- /dev/null +++ b/packages/desktop/src/renderer/initialization.ts @@ -0,0 +1,22 @@ +export function initializationData
(state: (() => A | undefined) & { error: unknown }) { + if (state.error !== undefined) throw markLocalServerStartup(state.error) + return state() +} + +function markLocalServerStartup(error: unknown) { + const failure = error instanceof Error ? error : new Error(String(error)) + const prefix = "Error invoking remote method 'await-initialization': Error: " + if (failure.message.startsWith(prefix)) { + const previous = failure.message + failure.message = failure.message.slice(prefix.length) + if (failure.stack) failure.stack = failure.stack.replace(`Error: ${previous}`, `Error: ${failure.message}`) + } + Object.defineProperty(failure, "localServerStartup", { value: true }) + return failure +} + +export function initializationReady(state: (() => A | undefined) & { error: unknown; loading: boolean }) { + if (state.loading) return false + initializationData(state) + return true +} diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5d7ff53896ab794c6e692831165939bceecd9f9 --- /dev/null +++ b/packages/desktop/src/renderer/onboarding.tsx @@ -0,0 +1,54 @@ +import { ServerConnection, useServer, useSettings, useTabs } from "@opencode-ai/app" +import { onMount } from "solid-js" + +export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) { + const server = useServer() + const settings = useSettings() + const tabs = useTabs() + + onMount(() => { + void runFirstLaunchOnboarding().finally(props.onLoaded) + }) + + async function runFirstLaunchOnboarding() { + try { + await Promise.all( + [server.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()), + ) + const existingInstall = await window.api.isOldLayoutEligible() + settings.general.setOldLayoutEligible(existingInstall) + settings.general.initializeAgentVisibility(existingInstall) + if (!server.isLocal()) return + + const pending = await window.api.isFirstLaunchOnboardingPending() + if (!pending) return + + const shouldTrigger = + !existingInstall && + props.initialUrl === "/" && + tabs.store.length === 0 && + server.list.every(ServerConnection.builtin) + + console.info("[desktop-onboarding] first launch onboarding evaluated", { + pending, + shouldTrigger, + existingInstall, + initialUrl: props.initialUrl, + tabs: tabs.store.length, + servers: server.list.map(ServerConnection.key), + }) + + const directory = await window.api.finishFirstLaunchOnboarding(shouldTrigger) + if (!shouldTrigger || !directory) return + + console.info("[desktop-onboarding] starting first launch draft", { directory }) + server.projects.open(directory) + server.projects.touch(directory) + tabs.select(await tabs.newDraft({ server: server.key, directory })) + } catch (error) { + console.error("[desktop-onboarding] first launch onboarding failed", error) + } + } + + return null +} diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/packages/desktop/src/renderer/webview-zoom.ts b/packages/desktop/src/renderer/webview-zoom.ts new file mode 100644 index 0000000000000000000000000000000000000000..7993bf5ee2321ce0514398c8e7f5892c84c2717d --- /dev/null +++ b/packages/desktop/src/renderer/webview-zoom.ts @@ -0,0 +1,138 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createSignal } from "solid-js" + +const OS_NAME = (() => { + if (navigator.userAgent.includes("Mac")) return "macos" + if (navigator.userAgent.includes("Windows")) return "windows" + if (navigator.userAgent.includes("Linux")) return "linux" + return "unknown" +})() + +const [webviewZoom, setWebviewZoom] = createSignal(1) +let requestedZoom = 1 +let pinchZoomEnabled = false +let wheelPinch = undefined as + | { + active: boolean + startZoom: number + totalDelta: number + timeout: ReturnType | undefined + } + | undefined + +const MAX_ZOOM_LEVEL = 10 +const MIN_ZOOM_LEVEL = 0.2 +const WHEEL_PINCH_THRESHOLD = 20 +const WHEEL_PINCH_STEP = 0.2 +const WHEEL_PINCH_END_DELAY = 160 + +const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) + +const applyZoom = (next: number) => { + requestedZoom = next + void window.api + .setZoomFactor(next) + .then(() => { + if (requestedZoom !== next) return + setWebviewZoom(next) + }) + .catch(() => { + if (requestedZoom !== next) return + requestedZoom = webviewZoom() + }) +} + +window.api.onZoomFactorChanged((factor) => { + requestedZoom = clamp(factor) + setWebviewZoom(requestedZoom) +}) + +void window.api.getPinchZoomEnabled().then((enabled) => { + pinchZoomEnabled = enabled +}) + +window.api.onPinchZoomEnabledChanged((enabled) => { + pinchZoomEnabled = enabled + resetWheelPinch() +}) + +const setPinchZoomEnabled = (enabled: boolean) => { + pinchZoomEnabled = enabled + resetWheelPinch() + return window.api.setPinchZoomEnabled(enabled) +} + +const resetZoom = () => applyZoom(1) +const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2)) +const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2)) + +const resetWheelPinch = () => { + clearTimeout(wheelPinch?.timeout) + wheelPinch = undefined +} + +const normalizeWheelDelta = (event: WheelEvent) => { + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16 + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * window.innerHeight + return event.deltaY +} + +const updateWheelPinch = (event: WheelEvent) => { + wheelPinch ??= { + active: false, + startZoom: requestedZoom, + totalDelta: 0, + timeout: undefined, + } + + clearTimeout(wheelPinch.timeout) + wheelPinch.timeout = setTimeout(resetWheelPinch, WHEEL_PINCH_END_DELAY) + wheelPinch.totalDelta += normalizeWheelDelta(event) + + if (!wheelPinch.active && Math.abs(wheelPinch.totalDelta) < WHEEL_PINCH_THRESHOLD) return + if (!wheelPinch.active) { + wheelPinch.active = true + wheelPinch.startZoom = requestedZoom + wheelPinch.totalDelta = 0 + return + } + + wheelPinch.active = true + applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP)) +} + +window.addEventListener( + "wheel", + (event) => { + if (!pinchZoomEnabled) return + if (!event.ctrlKey) return + + event.preventDefault() + updateWheelPinch(event) + }, + { passive: false }, +) + +window.addEventListener("keydown", (event) => { + if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return + + if (event.key === "-") { + event.preventDefault() + zoomOut() + return + } + if (event.key === "=" || event.key === "+") { + event.preventDefault() + zoomIn() + return + } + if (event.key === "0") { + event.preventDefault() + resetZoom() + } +}) + +export { webviewZoom, resetZoom, setPinchZoomEnabled, zoomIn, zoomOut } diff --git a/packages/desktop/src/renderer/window-fullscreen.ts b/packages/desktop/src/renderer/window-fullscreen.ts new file mode 100644 index 0000000000000000000000000000000000000000..95d91fa2e7e784ab6ba14c9ccf421e2f4ebb37b8 --- /dev/null +++ b/packages/desktop/src/renderer/window-fullscreen.ts @@ -0,0 +1,8 @@ +import { createSignal } from "solid-js" + +const [windowFullscreen, setWindowFullscreen] = createSignal(false) + +window.api.onWindowFullscreenChanged(setWindowFullscreen) +void window.api.getWindowFullscreen().then(setWindowFullscreen) + +export { windowFullscreen } diff --git a/packages/desktop/src/renderer/wsl/connections.test.ts b/packages/desktop/src/renderer/wsl/connections.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..057ed51e1d2ed475b1f102863eb30fb20e5ae898 --- /dev/null +++ b/packages/desktop/src/renderer/wsl/connections.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import type { WslServersState } from "@opencode-ai/app/wsl/types" +import { availableStartupServer, readyWslConnections } from "./connections" + +const state = (kind: "starting" | "ready" | "failed" | "stopped"): WslServersState => ({ + runtime: null, + installed: [], + online: [], + distroProbes: {}, + opencodeChecks: {}, + pendingRestart: false, + job: null, + servers: [ + { + config: { id: "wsl:Debian", distro: "Debian" }, + runtime: runtime(kind), + }, + ], +}) + +function runtime(kind: "starting" | "ready" | "failed" | "stopped") { + if (kind === "ready") return { kind, url: "http://127.0.0.1:4096", username: "opencode", password: "secret" } + if (kind === "failed") return { kind, message: "boom" } + return { kind } +} + +describe("WSL desktop connections", () => { + test("publishes a WSL server only after it reports ready", () => { + expect(readyWslConnections(state("starting"))).toEqual([]) + expect(readyWslConnections(state("failed"))).toEqual([]) + expect(readyWslConnections(state("stopped"))).toEqual([]) + expect(readyWslConnections(state("ready"))).toEqual([ + expect.objectContaining({ displayName: "Debian", label: "WSL" }), + ]) + }) + + test("uses the renderer translation for the WSL connection label", () => { + expect(readyWslConnections(state("ready"), "Translated WSL")[0]?.label).toBe("Translated WSL") + }) + + test("does not block desktop startup on a configured WSL default", () => { + const key = "wsl:Debian" + expect(availableStartupServer(key, undefined)).toBe("sidecar") + expect(availableStartupServer(key, state("starting"))).toBe("sidecar") + expect(availableStartupServer(key, state("ready"))).toBe(key) + }) +}) diff --git a/packages/desktop/src/renderer/wsl/connections.ts b/packages/desktop/src/renderer/wsl/connections.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5ef6a279f5fa9fd84ed84575479c08cb23610ea --- /dev/null +++ b/packages/desktop/src/renderer/wsl/connections.ts @@ -0,0 +1,28 @@ +import type { WslServersState } from "@opencode-ai/app/wsl/types" + +export function readyWslConnections(state?: WslServersState, label = "WSL") { + return (state?.servers ?? []).flatMap((item) => { + if (item.runtime.kind !== "ready") return [] + return [ + { + displayName: item.config.distro, + label, + type: "sidecar" as const, + variant: "wsl" as const, + distro: item.config.distro, + http: { + url: item.runtime.url, + username: item.runtime.username ?? undefined, + password: item.runtime.password ?? undefined, + }, + }, + ] + }) +} + +export function availableStartupServer(defaultServer: string | null | undefined, state?: WslServersState) { + const key = defaultServer ?? "sidecar" + if (!key.startsWith("wsl:")) return key + if (state?.servers.some((item) => item.config.id === key && item.runtime.kind === "ready")) return key + return "sidecar" +} diff --git a/packages/docs/ai-tools/claude-code.mdx b/packages/docs/ai-tools/claude-code.mdx new file mode 100644 index 0000000000000000000000000000000000000000..4039c6e0ea3deac4d815e97c111cdbc84b011af2 --- /dev/null +++ b/packages/docs/ai-tools/claude-code.mdx @@ -0,0 +1,83 @@ +--- +title: "Claude Code setup" +description: "Configure Claude Code for your documentation workflow" +icon: "asterisk" +--- + +Claude Code is Anthropic's official CLI tool. This guide will help you set up Claude Code to help you write and maintain your documentation. + +## Prerequisites + +- Active Claude subscription (Pro, Max, or API access) + +## Setup + +1. Install Claude Code globally: + +```bash +npm install -g @anthropic-ai/claude-code +``` + +2. Navigate to your docs directory. +3. (Optional) Add the `CLAUDE.md` file below to your project. +4. Run `claude` to start. + +## Create `CLAUDE.md` + +Create a `CLAUDE.md` file at the root of your documentation repository to train Claude Code on your specific documentation standards: + +```markdown +# Mintlify documentation + +## Working relationship + +- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so +- ALWAYS ask for clarification rather than making assumptions +- NEVER lie, guess, or make up information + +## Project context + +- Format: MDX files with YAML frontmatter +- Config: docs.json for navigation, theme, settings +- Components: Mintlify components + +## Content strategy + +- Document just enough for user success - not too much, not too little +- Prioritize accuracy and usability of information +- Make content evergreen when possible +- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason +- Check existing patterns for consistency +- Start by making the smallest reasonable changes + +## Frontmatter requirements for pages + +- title: Clear, descriptive page title +- description: Concise summary for SEO/navigation + +## Writing standards + +- Second-person voice ("you") +- Prerequisites at start of procedural content +- Test all code examples before publishing +- Match style and formatting of existing pages +- Include both basic and advanced use cases +- Language tags on all code blocks +- Alt text on all images +- Relative paths for internal links + +## Git workflow + +- NEVER use --no-verify when committing +- Ask how to handle uncommitted changes before starting +- Create a new branch when no clear branch exists for changes +- Commit frequently throughout development +- NEVER skip or disable pre-commit hooks + +## Do not + +- Skip frontmatter on any MDX file +- Use absolute URLs for internal links +- Include untested code examples +- Make assumptions - always ask for clarification +``` diff --git a/packages/docs/ai-tools/cursor.mdx b/packages/docs/ai-tools/cursor.mdx new file mode 100644 index 0000000000000000000000000000000000000000..d05882919fef83365d607e931956a08dd4b1f205 --- /dev/null +++ b/packages/docs/ai-tools/cursor.mdx @@ -0,0 +1,423 @@ +--- +title: "Cursor setup" +description: "Configure Cursor for your documentation workflow" +icon: "arrow-pointer" +--- + +Use Cursor to help write and maintain your documentation. This guide shows how to configure Cursor for better results on technical writing tasks and using Mintlify components. + +## Prerequisites + +- Cursor editor installed +- Access to your documentation repository + +## Project rules + +Create project rules that all team members can use. In your documentation repository root: + +```bash +mkdir -p .cursor +``` + +Create `.cursor/rules.md`: + +````markdown +# Mintlify technical writing rule + +You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. + +## Core writing principles + +### Language and style requirements + +- Use clear, direct language appropriate for technical audiences +- Write in second person ("you") for instructions and procedures +- Use active voice over passive voice +- Employ present tense for current states, future tense for outcomes +- Avoid jargon unless necessary and define terms when first used +- Maintain consistent terminology throughout all documentation +- Keep sentences concise while providing necessary context +- Use parallel structure in lists, headings, and procedures + +### Content organization standards + +- Lead with the most important information (inverted pyramid structure) +- Use progressive disclosure: basic concepts before advanced ones +- Break complex procedures into numbered steps +- Include prerequisites and context before instructions +- Provide expected outcomes for each major step +- Use descriptive, keyword-rich headings for navigation and SEO +- Group related information logically with clear section breaks + +### User-centered approach + +- Focus on user goals and outcomes rather than system features +- Anticipate common questions and address them proactively +- Include troubleshooting for likely failure points +- Write for scannability with clear headings, lists, and white space +- Include verification steps to confirm success + +## Mintlify component reference + +### Callout components + +#### Note - Additional helpful information + + +Supplementary information that supports the main content without interrupting flow + + +#### Tip - Best practices and pro tips + + +Expert advice, shortcuts, or best practices that enhance user success + + +#### Warning - Important cautions + + +Critical information about potential issues, breaking changes, or destructive actions + + +#### Info - Neutral contextual information + + +Background information, context, or neutral announcements + + +#### Check - Success confirmations + + +Positive confirmations, successful completions, or achievement indicators + + +### Code components + +#### Single code block + +Example of a single code block: + +```javascript config.js +const apiConfig = { + baseURL: "https://api.example.com", + timeout: 5000, + headers: { + Authorization: `Bearer ${process.env.API_TOKEN}`, + }, +} +``` + +#### Code group with multiple languages + +Example of a code group: + + +```javascript Node.js +const response = await fetch('/api/endpoint', { + headers: { Authorization: `Bearer ${apiKey}` } +}); +``` + +```python Python +import requests +response = requests.get('/api/endpoint', + headers={'Authorization': f'Bearer {api_key}'}) +``` + +```curl cURL +curl -X GET '/api/endpoint' \ + -H 'Authorization: Bearer YOUR_API_KEY' +``` + + + +#### Request/response examples + +Example of request/response documentation: + + +```bash cURL +curl -X POST 'https://api.example.com/users' \ + -H 'Content-Type: application/json' \ + -d '{"name": "John Doe", "email": "john@example.com"}' +``` + + + +```json Success +{ + "id": "user_123", + "name": "John Doe", + "email": "john@example.com", + "created_at": "2024-01-15T10:30:00Z" +} +``` + + +### Structural components + +#### Steps for procedures + +Example of step-by-step instructions: + + + + Run `npm install` to install required packages. + + + Verify installation by running `npm list`. + + + + + Create a `.env` file with your API credentials. + + ```bash + API_KEY=your_api_key_here + ``` + + + Never commit API keys to version control. + + + + +#### Tabs for alternative content + +Example of tabbed content: + + + + ```bash + brew install node + npm install -g package-name + ``` + + + + ```powershell + choco install nodejs + npm install -g package-name + ``` + + + + ```bash + sudo apt install nodejs npm + npm install -g package-name + ``` + + + +#### Accordions for collapsible content + +Example of accordion groups: + + + + - **Firewall blocking**: Ensure ports 80 and 443 are open + - **Proxy configuration**: Set HTTP_PROXY environment variable + - **DNS resolution**: Try using 8.8.8.8 as DNS server + + + + ```javascript + const config = { + performance: { cache: true, timeout: 30000 }, + security: { encryption: 'AES-256' } + }; + ``` + + + +### Cards and columns for emphasizing information + +Example of cards and card groups: + + +Complete walkthrough from installation to your first API call in under 10 minutes. + + + + + Learn how to authenticate requests using API keys or JWT tokens. + + + + Understand rate limits and best practices for high-volume usage. + + + +### API documentation components + +#### Parameter fields + +Example of parameter documentation: + + +Unique identifier for the user. Must be a valid UUID v4 format. + + + +User's email address. Must be valid and unique within the system. + + + +Maximum number of results to return. Range: 1-100. + + + +Bearer token for API authentication. Format: `Bearer YOUR_API_KEY` + + +#### Response fields + +Example of response field documentation: + + +Unique identifier assigned to the newly created user. + + + +ISO 8601 formatted timestamp of when the user was created. + + + +List of permission strings assigned to this user. + + +#### Expandable nested fields + +Example of nested field documentation: + + +Complete user object with all associated data. + + + + User profile information including personal details. + + + + User's first name as entered during registration. + + + + URL to user's profile picture. Returns null if no avatar is set. + + + + + + +### Media and advanced components + +#### Frames for images + +Wrap all images in frames: + + +Main dashboard showing analytics overview + + + +Analytics dashboard with charts + + +#### Videos + +Use the HTML video element for self-hosted video content: + + + +Embed YouTube videos using iframe elements: + + + +#### Tooltips + +Example of tooltip usage: + + +API + + +#### Updates + +Use updates for changelogs: + + +## New features +- Added bulk user import functionality +- Improved error messages with actionable suggestions + +## Bug fixes + +- Fixed pagination issue with large datasets +- Resolved authentication timeout problems + + +## Required page structure + +Every documentation page must begin with YAML frontmatter: + +```yaml +--- +title: "Clear, specific, keyword-rich title" +description: "Concise description explaining page purpose and value" +--- +``` + +## Content quality standards + +### Code examples requirements + +- Always include complete, runnable examples that users can copy and execute +- Show proper error handling and edge case management +- Use realistic data instead of placeholder values +- Include expected outputs and results for verification +- Test all code examples thoroughly before publishing +- Specify language and include filename when relevant +- Add explanatory comments for complex logic +- Never include real API keys or secrets in code examples + +### API documentation requirements + +- Document all parameters including optional ones with clear descriptions +- Show both success and error response examples with realistic data +- Include rate limiting information with specific limits +- Provide authentication examples showing proper format +- Explain all HTTP status codes and error handling +- Cover complete request/response cycles + +### Accessibility requirements + +- Include descriptive alt text for all images and diagrams +- Use specific, actionable link text instead of "click here" +- Ensure proper heading hierarchy starting with H2 +- Provide keyboard navigation considerations +- Use sufficient color contrast in examples and visuals +- Structure content for easy scanning with headers and lists + +## Component selection logic + +- Use **Steps** for procedures and sequential instructions +- Use **Tabs** for platform-specific content or alternative approaches +- Use **CodeGroup** when showing the same concept in multiple programming languages +- Use **Accordions** for progressive disclosure of information +- Use **RequestExample/ResponseExample** specifically for API endpoint documentation +- Use **ParamField** for API parameters, **ResponseField** for API responses +- Use **Expandable** for nested object properties or hierarchical information +```` diff --git a/packages/docs/ai-tools/windsurf.mdx b/packages/docs/ai-tools/windsurf.mdx new file mode 100644 index 0000000000000000000000000000000000000000..310c81d5f70fa47c9f5cd3c06bdbdbd2ae2a0a37 --- /dev/null +++ b/packages/docs/ai-tools/windsurf.mdx @@ -0,0 +1,96 @@ +--- +title: "Windsurf setup" +description: "Configure Windsurf for your documentation workflow" +icon: "water" +--- + +Configure Windsurf's Cascade AI assistant to help you write and maintain documentation. This guide shows how to set up Windsurf specifically for your Mintlify documentation workflow. + +## Prerequisites + +- Windsurf editor installed +- Access to your documentation repository + +## Workspace rules + +Create workspace rules that provide Windsurf with context about your documentation project and standards. + +Create `.windsurf/rules.md` in your project root: + +````markdown +# Mintlify technical writing rule + +## Project context + +- This is a documentation project on the Mintlify platform +- We use MDX files with YAML frontmatter +- Navigation is configured in `docs.json` +- We follow technical writing best practices + +## Writing standards + +- Use second person ("you") for instructions +- Write in active voice and present tense +- Start procedures with prerequisites +- Include expected outcomes for major steps +- Use descriptive, keyword-rich headings +- Keep sentences concise but informative + +## Required page structure + +Every page must start with frontmatter: + +```yaml +--- +title: "Clear, specific title" +description: "Concise description for SEO and navigation" +--- +``` + +## Mintlify components + +### Callouts + +- `` for helpful supplementary information +- `` for important cautions and breaking changes +- `` for best practices and expert advice +- `` for neutral contextual information +- `` for success confirmations + +### Code examples + +- When appropriate, include complete, runnable examples +- Use `` for multiple language examples +- Specify language tags on all code blocks +- Include realistic data, not placeholders +- Use `` and `` for API docs + +### Procedures + +- Use `` component for sequential instructions +- Include verification steps with `` components when relevant +- Break complex procedures into smaller steps + +### Content organization + +- Use `` for platform-specific content +- Use `` for progressive disclosure +- Use `` and `` for highlighting content +- Wrap images in `` components with descriptive alt text + +## API documentation requirements + +- Document all parameters with `` +- Show response structure with `` +- Include both success and error examples +- Use `` for nested object properties +- Always include authentication examples + +## Quality standards + +- Test all code examples before publishing +- Use relative paths for internal links +- Include alt text for all images +- Ensure proper heading hierarchy (start with h2) +- Check existing patterns for consistency +```` diff --git a/packages/docs/essentials/code.mdx b/packages/docs/essentials/code.mdx new file mode 100644 index 0000000000000000000000000000000000000000..7a0465447045e1010a8ca520c8239700c43f653f --- /dev/null +++ b/packages/docs/essentials/code.mdx @@ -0,0 +1,35 @@ +--- +title: "Code blocks" +description: "Display inline code and code blocks" +icon: "code" +--- + +## Inline code + +To denote a `word` or `phrase` as code, enclose it in backticks (`). + +``` +To denote a `word` or `phrase` as code, enclose it in backticks (`). +``` + +## Code blocks + +Use [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) by enclosing code in three backticks and follow the leading ticks with the programming language of your snippet to get syntax highlighting. Optionally, you can also write the name of your code after the programming language. + +```java HelloWorld.java +class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +``` + +````md +```java HelloWorld.java +class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +``` +```` diff --git a/packages/docs/essentials/images.mdx b/packages/docs/essentials/images.mdx new file mode 100644 index 0000000000000000000000000000000000000000..f2a10d2538e310a3fd008b1ca19e5521faa5f4f6 --- /dev/null +++ b/packages/docs/essentials/images.mdx @@ -0,0 +1,56 @@ +--- +title: "Images and embeds" +description: "Add image, video, and other HTML elements" +icon: "image" +--- + + + +## Image + +### Using Markdown + +The [markdown syntax](https://www.markdownguide.org/basic-syntax/#images) lets you add images using the following code + +```md +![title](/path/image.jpg) +``` + +Note that the image file size must be less than 5MB. Otherwise, we recommend hosting on a service like [Cloudinary](https://cloudinary.com/) or [S3](https://aws.amazon.com/s3/). You can then use that URL and embed. + +### Using embeds + +To get more customizability with images, you can also use [embeds](/writing-content/embed) to add images + +```html + +``` + +## Embeds and HTML elements + + + +
+ + + +Mintlify supports [HTML tags in Markdown](https://www.markdownguide.org/basic-syntax/#html). This is helpful if you prefer HTML tags to Markdown syntax, and lets you create documentation with infinite flexibility. + + + +### iFrames + +Loads another HTML page within the document. Most commonly used for embedding videos. + +```html + +``` diff --git a/packages/docs/essentials/markdown.mdx b/packages/docs/essentials/markdown.mdx new file mode 100644 index 0000000000000000000000000000000000000000..0ca5b8250970970525a8ece3d3b43874d25d5f3b --- /dev/null +++ b/packages/docs/essentials/markdown.mdx @@ -0,0 +1,88 @@ +--- +title: "Markdown syntax" +description: "Text, title, and styling in standard markdown" +icon: "text-size" +--- + +## Titles + +Best used for section headers. + +```md +## Titles +``` + +### Subtitles + +Best used for subsection headers. + +```md +### Subtitles +``` + + + +Each **title** and **subtitle** creates an anchor and also shows up on the table of contents on the right. + + + +## Text formatting + +We support most markdown formatting. Simply add `**`, `_`, or `~` around text to format it. + +| Style | How to write it | Result | +| ------------- | ----------------- | --------------- | +| Bold | `**bold**` | **bold** | +| Italic | `_italic_` | _italic_ | +| Strikethrough | `~strikethrough~` | ~strikethrough~ | + +You can combine these. For example, write `**_bold and italic_**` to get **_bold and italic_** text. + +You need to use HTML to write superscript and subscript text. That is, add `` or `` around your text. + +| Text Size | How to write it | Result | +| ----------- | ------------------------ | ---------------------- | +| Superscript | `superscript` | superscript | +| Subscript | `subscript` | subscript | + +## Linking to pages + +You can add a link by wrapping text in `[]()`. You would write `[link to google](https://google.com)` to [link to google](https://google.com). + +Links to pages in your docs need to be root-relative. Basically, you should include the entire folder path. For example, `[link to text](/writing-content/text)` links to the page "Text" in our components section. + +Relative links like `[link to text](../text)` will open slower because we cannot optimize them as easily. + +## Blockquotes + +### Singleline + +To create a blockquote, add a `>` in front of a paragraph. + +> Dorothy followed her through many of the beautiful rooms in her castle. + +```md +> Dorothy followed her through many of the beautiful rooms in her castle. +``` + +### Multiline + +> Dorothy followed her through many of the beautiful rooms in her castle. +> +> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood. + +```md +> Dorothy followed her through many of the beautiful rooms in her castle. +> +> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood. +``` + +### LaTeX + +Mintlify supports [LaTeX](https://www.latex-project.org) through the Latex component. + +8 x (vk x H1 - H2) = (0,1) + +```md +8 x (vk x H1 - H2) = (0,1) +``` diff --git a/packages/docs/essentials/navigation.mdx b/packages/docs/essentials/navigation.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a6a3090046d4ca23170fa5217837a619eb68678c --- /dev/null +++ b/packages/docs/essentials/navigation.mdx @@ -0,0 +1,87 @@ +--- +title: "Navigation" +description: "The navigation field in docs.json defines the pages that go in the navigation menu" +icon: "map" +--- + +The navigation menu is the list of links on every website. + +You will likely update `docs.json` every time you add a new page. Pages do not show up automatically. + +## Navigation syntax + +Our navigation syntax is recursive which means you can make nested navigation groups. You don't need to include `.mdx` in page names. + + + +```json Regular Navigation +"navigation": { + "tabs": [ + { + "tab": "Docs", + "groups": [ + { + "group": "Getting Started", + "pages": ["quickstart"] + } + ] + } + ] +} +``` + +```json Nested Navigation +"navigation": { + "tabs": [ + { + "tab": "Docs", + "groups": [ + { + "group": "Getting Started", + "pages": [ + "quickstart", + { + "group": "Nested Reference Pages", + "pages": ["nested-reference-page"] + } + ] + } + ] + } + ] +} +``` + + + +## Folders + +Simply put your MDX files in folders and update the paths in `docs.json`. + +For example, to have a page at `https://yoursite.com/your-folder/your-page` you would make a folder called `your-folder` containing an MDX file called `your-page.mdx`. + + + +You cannot use `api` for the name of a folder unless you nest it inside another folder. Mintlify uses Next.js which reserves the top-level `api` folder for internal server calls. A folder name such as `api-reference` would be accepted. + + + +```json Navigation With Folder +"navigation": { + "tabs": [ + { + "tab": "Docs", + "groups": [ + { + "group": "Group Name", + "pages": ["your-folder/your-page"] + } + ] + } + ] +} +``` + +## Hidden pages + +MDX files not included in `docs.json` will not show up in the sidebar but are accessible through the search bar and by linking directly to them. diff --git a/packages/docs/essentials/reusable-snippets.mdx b/packages/docs/essentials/reusable-snippets.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a26ab89a35131e8cf5f73031244640fe2ac5a837 --- /dev/null +++ b/packages/docs/essentials/reusable-snippets.mdx @@ -0,0 +1,112 @@ +--- +title: "Reusable snippets" +description: "Reusable, custom snippets to keep content in sync" +icon: "recycle" +--- + +import SnippetIntro from "/snippets/snippet-intro.mdx" + + + +## Creating a custom snippet + +**Pre-condition**: You must create your snippet file in the `snippets` directory. + + + Any page in the `snippets` directory will be treated as a snippet and will not be rendered into a standalone page. If + you want to create a standalone page from the snippet, import the snippet into another file and call it as a + component. + + +### Default export + +1. Add content to your snippet file that you want to re-use across multiple + locations. Optionally, you can add variables that can be filled in via props + when you import the snippet. + +```mdx snippets/my-snippet.mdx +Hello world! This is my content I want to reuse across pages. My keyword of the +day is {word}. +``` + + + The content that you want to reuse must be inside the `snippets` directory in order for the import to work. + + +2. Import the snippet into your destination file. + +```mdx destination-file.mdx +--- +title: My title +description: My Description +--- + +import MySnippet from "/snippets/path/to/my-snippet.mdx" + +## Header + +Lorem impsum dolor sit amet. + + +``` + +### Reusable variables + +1. Export a variable from your snippet file: + +```mdx snippets/path/to/custom-variables.mdx +export const myName = "my name" + +export const myObject = { fruit: "strawberries" } + +; +``` + +2. Import the snippet from your destination file and use the variable: + +```mdx destination-file.mdx +--- +title: My title +description: My Description +--- + +import { myName, myObject } from "/snippets/path/to/custom-variables.mdx" + +Hello, my name is {myName} and I like {myObject.fruit}. +``` + +### Reusable components + +1. Inside your snippet file, create a component that takes in props by exporting + your component in the form of an arrow function. + +```mdx snippets/custom-component.mdx +export const MyComponent = ({ title }) => ( +
+

{title}

+

... snippet content ...

+
+) + +; +``` + + + MDX does not compile inside the body of an arrow function. Stick to HTML syntax when you can or use a default export + if you need to use MDX. + + +2. Import the snippet into your destination file and pass in the props + +```mdx destination-file.mdx +--- +title: My title +description: My Description +--- + +import { MyComponent } from "/snippets/custom-component.mdx" + +Lorem ipsum dolor sit amet. + + +``` diff --git a/packages/docs/essentials/settings.mdx b/packages/docs/essentials/settings.mdx new file mode 100644 index 0000000000000000000000000000000000000000..2cc202ed1fb7b39695e7466d83da309d2bcbcb4c --- /dev/null +++ b/packages/docs/essentials/settings.mdx @@ -0,0 +1,316 @@ +--- +title: "Global Settings" +description: "Mintlify gives you complete control over the look and feel of your documentation using the docs.json file" +icon: "gear" +--- + +Every Mintlify site needs a `docs.json` file with the core configuration settings. Learn more about the [properties](#properties) below. + +## Properties + + +Name of your project. Used for the global title. + +Example: `mintlify` + + + + + An array of groups with all the pages within that group + + + The name of the group. + + Example: `Settings` + + + + The relative paths to the markdown files that will serve as pages. + + Example: `["customization", "page"]` + + + + + + + + Path to logo image or object with path to "light" and "dark" mode logo images + + + Path to the logo in light mode + + + Path to the logo in dark mode + + + Where clicking on the logo links you to + + + + + + Path to the favicon image + + + + Hex color codes for your global theme + + + The primary color. Used most often for highlighted content, section headers, accents, in light mode + + + The primary color for dark mode. Used most often for highlighted content, section headers, accents, in dark mode + + + The primary color for important buttons + + + The color of the background in both light and dark mode + + + The hex color code of the background in light mode + + + The hex color code of the background in dark mode + + + + + + + + Array of `name`s and `url`s of links you want to include in the topbar + + + The name of the button. + + Example: `Contact us` + + + The url once you click on the button. Example: `https://mintlify.com/docs` + + + + + + + + + Link shows a button. GitHub shows the repo information at the url provided including the number of GitHub stars. + + + If `link`: What the button links to. + + If `github`: Link to the repository to load GitHub information from. + + + Text inside the button. Only required if `type` is a `link`. + + + + + + + Array of version names. Only use this if you want to show different versions of docs with a dropdown in the navigation + bar. + + + + An array of the anchors, includes the `icon`, `color`, and `url`. + + + The [Font Awesome](https://fontawesome.com/search?q=heart) icon used to feature the anchor. + + Example: `comments` + + + The name of the anchor label. + + Example: `Community` + + + The start of the URL that marks what pages go in the anchor. Generally, this is the name of the folder you put your pages in. + + + The hex color of the anchor icon background. Can also be a gradient if you pass an object with the properties `from` and `to` that are each a hex color. + + + Used if you want to hide an anchor until the correct docs version is selected. + + + Pass `true` if you want to hide the anchor until you directly link someone to docs inside it. + + + One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin" + + + + + + + Override the default configurations for the top-most anchor. + + + The name of the top-most anchor + + + Font Awesome icon. + + + One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin" + + + + + + An array of navigational tabs. + + + The name of the tab label. + + + The start of the URL that marks what pages go in the tab. Generally, this is the name of the folder you put your + pages in. + + + + + + Configuration for API settings. Learn more about API pages at [API Components](/api-playground/demo). + + + The base url for all API endpoints. If `baseUrl` is an array, it will enable for multiple base url + options that the user can toggle. + + + + + + The authentication strategy used for all API endpoints. + + + The name of the authentication parameter used in the API playground. + + If method is `basic`, the format should be `[usernameName]:[passwordName]` + + + The default value that's designed to be a prefix for the authentication input field. + + E.g. If an `inputPrefix` of `AuthKey` would inherit the default input result of the authentication field as `AuthKey`. + + + + + + Configurations for the API playground + + + + Whether the playground is showing, hidden, or only displaying the endpoint with no added user interactivity `simple` + + Learn more at the [playground guides](/api-playground/demo) + + + + + + Enabling this flag ensures that key ordering in OpenAPI pages matches the key ordering defined in the OpenAPI file. + + This behavior will soon be enabled by default, at which point this field will be deprecated. + + + + + + + A string or an array of strings of URL(s) or relative path(s) pointing to your + OpenAPI file. + + Examples: + + ```json Absolute + "openapi": "https://example.com/openapi.json" + ``` + ```json Relative + "openapi": "/openapi.json" + ``` + ```json Multiple + "openapi": ["https://example.com/openapi1.json", "/openapi2.json", "/openapi3.json"] + ``` + + + + + + An object of social media accounts where the key:property pair represents the social media platform and the account url. + + Example: + ```json + { + "x": "https://x.com/mintlify", + "website": "https://mintlify.com" + } + ``` + + + One of the following values `website`, `facebook`, `x`, `discord`, `slack`, `github`, `linkedin`, `instagram`, `hacker-news` + + Example: `x` + + + The URL to the social platform. + + Example: `https://x.com/mintlify` + + + + + + Configurations to enable feedback buttons + + + + Enables a button to allow users to suggest edits via pull requests + + + Enables a button to allow users to raise an issue about the documentation + + + + + + Customize the dark mode toggle. + + + Set if you always want to show light or dark mode for new users. When not + set, we default to the same mode as the user's operating system. + + + Set to true to hide the dark/light mode toggle. You can combine `isHidden` with `default` to force your docs to only use light or dark mode. For example: + + + ```json Only Dark Mode + "modeToggle": { + "default": "dark", + "isHidden": true + } + ``` + + ```json Only Light Mode + "modeToggle": { + "default": "light", + "isHidden": true + } + ``` + + + + + + + + + A background image to be displayed behind every page. See example with [Infisical](https://infisical.com/docs) and + [FRPC](https://frpc.io). + diff --git a/packages/docs/logo/dark.svg b/packages/docs/logo/dark.svg new file mode 100644 index 0000000000000000000000000000000000000000..8b343cd6fc9095a51d8d5287ff1ad6297ef9937f --- /dev/null +++ b/packages/docs/logo/dark.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/logo/light.svg b/packages/docs/logo/light.svg new file mode 100644 index 0000000000000000000000000000000000000000..03e62bf1d9fcb79434827929c323ea2e2d5676cf --- /dev/null +++ b/packages/docs/logo/light.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/snippets/snippet-intro.mdx b/packages/docs/snippets/snippet-intro.mdx new file mode 100644 index 0000000000000000000000000000000000000000..e20fbb6fc93b64606e427b5eee54459048d5e7fb --- /dev/null +++ b/packages/docs/snippets/snippet-intro.mdx @@ -0,0 +1,4 @@ +One of the core principles of software development is DRY (Don't Repeat +Yourself). This is a principle that applies to documentation as +well. If you find yourself repeating the same content in multiple places, you +should consider creating a custom snippet to keep your content in sync. diff --git a/packages/effect-sqlite-node/src/index.ts b/packages/effect-sqlite-node/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..37e255391da1904c3ae1dba6cd286dc1711ba2fe --- /dev/null +++ b/packages/effect-sqlite-node/src/index.ts @@ -0,0 +1,168 @@ +export * as NodeSqliteClient from "./index" + +import { DatabaseSync, type SQLInputValue } from "node:sqlite" +import { identity } from "effect/Function" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import * as Layer from "effect/Layer" +import * as Scope from "effect/Scope" +import * as Semaphore from "effect/Semaphore" +import * as Stream from "effect/Stream" +import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import * as Client from "effect/unstable/sql/SqlClient" +import type { Connection } from "effect/unstable/sql/SqlConnection" +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" +import * as Statement from "effect/unstable/sql/Statement" + +const ATTR_DB_SYSTEM_NAME = "db.system.name" + +export const TypeId: TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient" +export type TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient" + +export interface SqliteClient extends Client.SqlClient { + readonly [TypeId]: TypeId + readonly config: SqliteClientConfig + readonly loadExtension: (path: string) => Effect.Effect + readonly updateValues: never +} + +export const SqliteClient = Context.Service("@opencode-ai/effect-sqlite-node/NodeSqliteClient") + +export interface SqliteClientConfig { + readonly filename: string + readonly readonly?: boolean | undefined + readonly create?: boolean | undefined + readonly readwrite?: boolean | undefined + readonly disableWAL?: boolean | undefined + readonly timeout?: number | undefined + readonly allowExtension?: boolean | undefined + readonly spanAttributes?: Record | undefined + readonly transformResultNames?: ((str: string) => string) | undefined + readonly transformQueryNames?: ((str: string) => string) | undefined +} + +interface SqliteConnection extends Connection { + readonly loadExtension: (path: string) => Effect.Effect +} + +export const make = ( + options: SqliteClientConfig, +): Effect.Effect => + Effect.gen(function* () { + const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) + const transformRows = options.transformResultNames + ? Statement.defaultTransforms(options.transformResultNames).array + : undefined + + const makeConnection = Effect.gen(function* () { + const db = new DatabaseSync(options.filename, { + readOnly: options.readonly, + timeout: options.timeout, + allowExtension: options.allowExtension, + enableForeignKeyConstraints: true, + open: true, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => db.close())) + + if (options.disableWAL !== true && options.readonly !== true) { + db.exec("PRAGMA journal_mode = WAL;") + } + + const run = (sql: string, params: ReadonlyArray = []) => + Effect.withFiber>, SqlError>((fiber) => { + const statement = db.prepare(sql) + statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + try { + return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array>) + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }), + ) + } + }) + + const runValues = (sql: string, params: ReadonlyArray = []) => + Effect.withFiber>, SqlError>((fiber) => { + const statement = db.prepare(sql) + statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + statement.setReturnArrays(true) + try { + return Effect.succeed( + statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, + ) + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }), + ) + } + }) + + return identity({ + execute(sql, params, transformRows) { + return transformRows ? Effect.map(run(sql, params), transformRows) : run(sql, params) + }, + executeRaw(sql, params) { + return run(sql, params) + }, + executeValues(sql, params) { + return runValues(sql, params) + }, + executeUnprepared(sql, params, transformRows) { + return this.execute(sql, params, transformRows) + }, + executeStream() { + return Stream.die("executeStream not implemented") + }, + loadExtension: (path) => + Effect.try({ + try: () => db.loadExtension(path), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to load extension", operation: "loadExtension" }), + }), + }), + }) + }) + + const semaphore = yield* Semaphore.make(1) + const connection = yield* makeConnection + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)) + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()! + const scope = Context.getUnsafe(fiber.context, Scope.Scope) + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), + connection, + ) + }) + + return Object.assign( + (yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), + [ATTR_DB_SYSTEM_NAME, "sqlite"], + ], + transformRows, + })) as SqliteClient, + { + [TypeId]: TypeId as TypeId, + config: options, + loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)), + }, + ) + }) + +export const layer = (config: SqliteClientConfig): Layer.Layer => + Layer.effectContext( + Effect.map(make(config), (client) => + Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), + ), + ).pipe(Layer.provide(Reactivity.layer)) diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..e57a567dca24a9dfa2283551f1267a0768e4ff57 --- /dev/null +++ b/packages/function/src/api.ts @@ -0,0 +1,388 @@ +import { Hono } from "hono" +import { DurableObject } from "cloudflare:workers" +import { randomUUID } from "node:crypto" +import { jwtVerify, createRemoteJWKSet } from "jose" +import { createAppAuth } from "@octokit/auth-app" +import { Octokit } from "@octokit/rest" +import { Resource } from "sst" +import { parseRepositoryClaim } from "./github" + +type Env = { + SYNC_SERVER: DurableObjectNamespace + Bucket: R2Bucket + WEB_DOMAIN: string +} + +export class SyncServer extends DurableObject { + // oxlint-disable-next-line no-useless-constructor + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env) + } + async fetch() { + console.log("SyncServer subscribe") + + const webSocketPair = new WebSocketPair() + const [client, server] = Object.values(webSocketPair) + + this.ctx.acceptWebSocket(server) + + const data = await this.ctx.storage.list() + Array.from(data.entries()) + .filter(([key, _]) => key.startsWith("session/")) + .map(([key, content]) => server.send(JSON.stringify({ key, content }))) + + return new Response(null, { + status: 101, + webSocket: client, + }) + } + + async webSocketMessage(_ws, _message) {} + + async webSocketClose(ws, code, _reason, _wasClean) { + ws.close(code, "Durable Object is closing WebSocket") + } + + async publish(key: string, content: any) { + const sessionID = await this.getSessionID() + if ( + !key.startsWith(`session/info/${sessionID}`) && + !key.startsWith(`session/message/${sessionID}/`) && + !key.startsWith(`session/part/${sessionID}/`) + ) + return new Response("Error: Invalid key", { status: 400 }) + + // store message + await this.env.Bucket.put(`share/${key}.json`, JSON.stringify(content), { + httpMetadata: { + contentType: "application/json", + }, + }) + await this.ctx.storage.put(key, content) + const clients = this.ctx.getWebSockets() + console.log("SyncServer publish", key, "to", clients.length, "subscribers") + for (const client of clients) { + client.send(JSON.stringify({ key, content })) + } + } + + public async share(sessionID: string) { + let secret = await this.getSecret() + if (secret) return secret + secret = randomUUID() + + await this.ctx.storage.put("secret", secret) + await this.ctx.storage.put("sessionID", sessionID) + + return secret + } + + public async getData() { + const data = (await this.ctx.storage.list()) as Map + return Array.from(data.entries()) + .filter(([key, _]) => key.startsWith("session/")) + .map(([key, content]) => ({ key, content })) + } + + public async assertSecret(secret: string) { + if (secret !== (await this.getSecret())) throw new Error("Invalid secret") + } + + private async getSecret() { + return this.ctx.storage.get("secret") + } + + private async getSessionID() { + return this.ctx.storage.get("sessionID") + } + + async clear() { + const sessionID = await this.getSessionID() + const list = await this.env.Bucket.list({ + prefix: `session/message/${sessionID}/`, + limit: 1000, + }) + for (const item of list.objects) { + await this.env.Bucket.delete(item.key) + } + await this.env.Bucket.delete(`session/info/${sessionID}`) + await this.ctx.storage.deleteAll() + } + + static shortName(id: string) { + return id.substring(id.length - 8) + } +} + +export default new Hono<{ Bindings: Env }>() + .get("/", (c) => c.text("Hello, world!")) + .post("/share_create", async (c) => { + const body = await c.req.json<{ sessionID: string }>() + const sessionID = body.sessionID + const short = SyncServer.shortName(sessionID) + const id = c.env.SYNC_SERVER.idFromName(short) + const stub = c.env.SYNC_SERVER.get(id) + const secret = await stub.share(sessionID) + return c.json({ + secret, + url: `https://${c.env.WEB_DOMAIN}/s/${short}`, + }) + }) + .post("/share_delete", async (c) => { + const body = await c.req.json<{ sessionID: string; secret: string }>() + const sessionID = body.sessionID + const secret = body.secret + const id = c.env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID)) + const stub = c.env.SYNC_SERVER.get(id) + await stub.assertSecret(secret) + await stub.clear() + return c.json({}) + }) + .post("/share_delete_admin", async (c) => { + const body = await c.req.json<{ sessionShortName: string; adminSecret: string }>() + const sessionShortName = body.sessionShortName + const adminSecret = body.adminSecret + if (adminSecret !== Resource.ADMIN_SECRET.value) throw new Error("Invalid admin secret") + const id = c.env.SYNC_SERVER.idFromName(sessionShortName) + const stub = c.env.SYNC_SERVER.get(id) + await stub.clear() + return c.json({}) + }) + .post("/share_sync", async (c) => { + const body = await c.req.json<{ + sessionID: string + secret: string + key: string + content: any + }>() + const name = SyncServer.shortName(body.sessionID) + const id = c.env.SYNC_SERVER.idFromName(name) + const stub = c.env.SYNC_SERVER.get(id) + await stub.assertSecret(body.secret) + await stub.publish(body.key, body.content) + return c.json({}) + }) + .get("/share_poll", async (c) => { + const upgradeHeader = c.req.header("Upgrade") + if (!upgradeHeader || upgradeHeader !== "websocket") { + return c.text("Error: Upgrade header is required", { status: 426 }) + } + const id = c.req.query("id") + console.log("share_poll", id) + if (!id) return c.text("Error: Share ID is required", { status: 400 }) + const stub = c.env.SYNC_SERVER.get(c.env.SYNC_SERVER.idFromName(id)) + return stub.fetch(c.req.raw) + }) + .get("/share_data", async (c) => { + const id = c.req.query("id") + console.log("share_data", id) + if (!id) return c.text("Error: Share ID is required", { status: 400 }) + const stub = c.env.SYNC_SERVER.get(c.env.SYNC_SERVER.idFromName(id)) + const data = await stub.getData() + + let info + const messages: Record = {} + data.forEach((d) => { + const [root, type] = d.key.split("/") + if (root !== "session") return + if (type === "info") { + info = d.content + return + } + if (type === "message") { + messages[d.content.id] = { + parts: [], + ...d.content, + } + } + if (type === "part") { + messages[d.content.messageID].parts.push(d.content) + } + }) + + return c.json({ info, messages }) + }) + .post("/feishu", async (c) => { + const body = (await c.req.json()) as { + challenge?: string + event?: { + message?: { + message_id?: string + root_id?: string + parent_id?: string + chat_id?: string + content?: string + } + } + } + console.log(JSON.stringify(body, null, 2)) + const challenge = body.challenge + if (challenge) return c.json({ challenge }) + + const content = body.event?.message?.content + const parsed = + typeof content === "string" && content.trim().startsWith("{") + ? (JSON.parse(content) as { + text?: string + }) + : undefined + const text = typeof parsed?.text === "string" ? parsed.text : typeof content === "string" ? content : "" + + let message = text.trim().replace(/^@_user_\d+\s*/, "") + message = message.replace(/^aiden,?\s*/i, "<@759257817772851260> ") + if (!message) return c.json({ ok: true }) + + const threadId = body.event?.message?.root_id || body.event?.message?.message_id + if (threadId) message = `${message} [${threadId}]` + + const response = await fetch( + `https://discord.com/api/v10/channels/${Resource.DISCORD_SUPPORT_CHANNEL_ID.value}/messages`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${Resource.DISCORD_SUPPORT_BOT_TOKEN.value}`, + }, + body: JSON.stringify({ + content: `${message}`, + }), + }, + ) + + if (!response.ok) { + console.error(await response.text()) + return c.json({ error: "Discord bot message failed" }, { status: 502 }) + } + + return c.json({ ok: true }) + }) + /** + * Used by the GitHub action to get GitHub installation access token given the OIDC token + */ + .post("/exchange_github_app_token", async (c) => { + const EXPECTED_AUDIENCE = "opencode-github-action" + const GITHUB_ISSUER = "https://token.actions.githubusercontent.com" + const JWKS_URL = `${GITHUB_ISSUER}/.well-known/jwks` + + // get Authorization header + const token = c.req.header("Authorization")?.replace(/^Bearer /, "") + if (!token) return c.json({ error: "Authorization header is required" }, { status: 401 }) + + // verify token + const JWKS = createRemoteJWKSet(new URL(JWKS_URL)) + let repository: ReturnType + try { + const { payload } = await jwtVerify(token, JWKS, { + issuer: GITHUB_ISSUER, + audience: EXPECTED_AUDIENCE, + }) + repository = parseRepositoryClaim(payload) + } catch (err) { + console.error("Token verification failed:", err) + return c.json({ error: "Invalid or expired token" }, { status: 403 }) + } + + try { + const auth = createAppAuth({ + appId: Resource.GITHUB_APP_ID.value, + privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, + }) + const appAuth = await auth({ type: "app" }) + const octokit = new Octokit({ auth: appAuth.token }) + const { data: installation } = await octokit.apps.getRepoInstallation({ + owner: repository.owner, + repo: repository.repo, + }) + const installationAuth = await auth({ + type: "installation", + installationId: installation.id, + }) + return c.json({ token: installationAuth.token }) + } catch (error) { + console.error("GitHub App token exchange failed:", error) + return c.json( + { error: `Failed to exchange GitHub App token for ${repository.owner}/${repository.repo}` }, + { status: 502 }, + ) + } + }) + /** + * Used by the GitHub action to get GitHub installation access token given user PAT token (used when testing `opencode github run` locally) + */ + .post("/exchange_github_app_token_with_pat", async (c) => { + const body = await c.req.json<{ owner: string; repo: string }>() + const owner = body.owner + const repo = body.repo + + try { + // get Authorization header + const authHeader = c.req.header("Authorization") + const token = authHeader?.replace(/^Bearer /, "") + if (!token) throw new Error("Authorization header is required") + + // Verify permissions + const userClient = new Octokit({ auth: token }) + const { data: repoData } = await userClient.repos.get({ owner, repo }) + if (!repoData.permissions.admin && !repoData.permissions.push && !repoData.permissions.maintain) + throw new Error("User does not have write permissions") + + // Get installation token + const auth = createAppAuth({ + appId: Resource.GITHUB_APP_ID.value, + privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, + }) + const appAuth = await auth({ type: "app" }) + + // Lookup installation + const appClient = new Octokit({ auth: appAuth.token }) + const { data: installation } = await appClient.apps.getRepoInstallation({ + owner, + repo, + }) + + // Get installation token + const installationAuth = await auth({ + type: "installation", + installationId: installation.id, + }) + + return c.json({ token: installationAuth.token }) + } catch (e: any) { + let error = e + if (e instanceof Error) { + error = e.message + } + + return c.json({ error }, { status: 401 }) + } + }) + /** + * Used by the opencode CLI to check if the GitHub app is installed + */ + .get("/get_github_app_installation", async (c) => { + const owner = c.req.query("owner") + const repo = c.req.query("repo") + + const auth = createAppAuth({ + appId: Resource.GITHUB_APP_ID.value, + privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, + }) + const appAuth = await auth({ type: "app" }) + + // Lookup installation + const octokit = new Octokit({ auth: appAuth.token }) + let installation + try { + const ret = await octokit.apps.getRepoInstallation({ owner, repo }) + installation = ret.data + } catch (err) { + if (err instanceof Error && err.message.includes("Not Found")) { + // not installed + } else { + throw err + } + } + + return c.json({ installation }) + }) + .all("*", (c) => c.text("Not Found")) diff --git a/packages/function/src/github.ts b/packages/function/src/github.ts new file mode 100644 index 0000000000000000000000000000000000000000..180d377131e8d57cc8c416d045c8007b26253ec8 --- /dev/null +++ b/packages/function/src/github.ts @@ -0,0 +1,14 @@ +import type { JWTPayload } from "jose" + +export function parseRepositoryClaim(payload: JWTPayload) { + const claim = payload.repository + if (typeof claim !== "string") throw new Error("Repository claim is missing") + + const parts = claim.split("/") + if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("Repository claim is invalid") + + return { + owner: parts[0], + repo: parts[1], + } +} diff --git a/packages/function/test/github.test.ts b/packages/function/test/github.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f5fbac9534f1b33cf7005b91b1cd5f4fd32b44f --- /dev/null +++ b/packages/function/test/github.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { parseRepositoryClaim } from "../src/github" + +describe("parseRepositoryClaim", () => { + test("reads repository identity with a legacy subject", () => { + expect( + parseRepositoryClaim({ + repository: "octocat/my-repo", + sub: "repo:octocat/my-repo:ref:refs/heads/main", + }), + ).toEqual({ owner: "octocat", repo: "my-repo" }) + }) + + test("reads repository identity with an immutable subject", () => { + expect( + parseRepositoryClaim({ + repository: "octocat/my-repo", + sub: "repo:octocat@123456/my-repo@456789:ref:refs/heads/main", + }), + ).toEqual({ owner: "octocat", repo: "my-repo" }) + }) + + test("does not depend on a repository path in a customized subject", () => { + expect( + parseRepositoryClaim({ + repository: "octocat/my-repo", + sub: "repository_owner:octocat:repository_visibility:private", + }), + ).toEqual({ owner: "octocat", repo: "my-repo" }) + }) + + test("rejects a missing repository claim", () => { + expect(() => parseRepositoryClaim({})).toThrow("Repository claim is missing") + }) + + test("rejects an invalid repository claim", () => { + expect(() => parseRepositoryClaim({ repository: "octocat" })).toThrow("Repository claim is invalid") + }) +}) diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts new file mode 100644 index 0000000000000000000000000000000000000000..fea8c7230cf1ec69360285da04934153a7a24cc0 --- /dev/null +++ b/packages/plugin/script/publish.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env bun +import { Script } from "@opencode-ai/script" +import { $ } from "bun" +import { fileURLToPath } from "url" + +const dir = fileURLToPath(new URL("..", import.meta.url)) +process.chdir(dir) + +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + +await $`bun tsc` +const originalText = await Bun.file("package.json").text() +const pkg = JSON.parse(originalText) as { + name: string + version: string + exports: Record +} +if (await published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) +} else { + for (const [key, value] of Object.entries(pkg.exports)) { + const file = value.replace("./src/", "./dist/").replace(".ts", "") + // @ts-ignore + pkg.exports[key] = { + import: file + ".js", + types: file + ".d.ts", + } + } + await Bun.write("package.json", JSON.stringify(pkg, null, 2)) + try { + await $`bun pm pack` + await $`npm publish *.tgz --tag ${Script.channel} --access public` + } finally { + await Bun.write("package.json", originalText) + } +} diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c6daa34d04ab61dd206360c1f187cfd694e668a --- /dev/null +++ b/packages/plugin/src/tool.ts @@ -0,0 +1,54 @@ +import { z } from "zod" + +export type ToolContext = { + sessionID: string + messageID: string + agent: string + /** + * Current project directory for this session. + * Prefer this over process.cwd() when resolving relative paths. + */ + directory: string + /** + * Project worktree root for this session. + * Useful for generating stable relative paths (e.g. path.relative(worktree, absPath)). + */ + worktree: string + abort: AbortSignal + metadata(input: { title?: string; metadata?: { [key: string]: any } }): void + ask(input: AskInput): Promise +} + +type AskInput = { + permission: string + patterns: string[] + always: string[] + metadata: { [key: string]: any } +} + +export type ToolAttachment = { + type: "file" + mime: string + url: string + filename?: string +} + +export type ToolResult = + | string + | { + title?: string + output: string + metadata?: { [key: string]: any } + attachments?: ToolAttachment[] + } + +export function tool(input: { + description: string + args: Args + execute(args: z.infer>, context: ToolContext): Promise +}) { + return input +} +tool.schema = z + +export type ToolDefinition = ReturnType diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b422d06007d65407e3e36e2c022a6bd2013d0e9 --- /dev/null +++ b/packages/protocol/src/api.ts @@ -0,0 +1,86 @@ +import { Context } from "effect" +import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" +import { SchemaErrorMiddleware } from "./middleware/schema-error" +import { MessageGroup } from "./groups/message" +import { ModelGroup } from "./groups/model" +import { ProviderGroup } from "./groups/provider" +import { makeSessionGroup } from "./groups/session" +import { makePermissionGroup } from "./groups/permission" +import { FileSystemGroup } from "./groups/fs" +import { CommandGroup } from "./groups/command" +import { SkillGroup } from "./groups/skill" +import { EventGroup, makeEventGroup } from "./groups/event" +import type { Definition } from "@opencode-ai/schema/event" +import { AgentGroup } from "./groups/agent" +import { HealthGroup } from "./groups/health" +import { PtyGroup } from "./groups/pty" +import { makeQuestionGroup } from "./groups/question" +import { ReferenceGroup } from "./groups/reference" +import { Authorization } from "./middleware/authorization" +import { LocationGroup } from "./groups/location" +import { IntegrationGroup } from "./groups/integration" +import { CredentialGroup } from "./groups/credential" +import { ProjectCopyGroup } from "./groups/project-copy" + +// Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream. +const makeApiFromGroup = < + const Group extends HttpApiGroup.Any, + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + eventGroup: Group, + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApi.make("server") + .add(HealthGroup) + .add(LocationGroup.middleware(locationMiddleware)) + .add(AgentGroup.middleware(locationMiddleware)) + .add(makeSessionGroup(sessionLocationMiddleware)) + .add(MessageGroup.middleware(sessionLocationMiddleware)) + .add(ModelGroup.middleware(locationMiddleware)) + .add(ProviderGroup.middleware(locationMiddleware)) + .add(IntegrationGroup.middleware(locationMiddleware)) + .add(CredentialGroup.middleware(locationMiddleware)) + .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(FileSystemGroup.middleware(locationMiddleware)) + .add(CommandGroup.middleware(locationMiddleware)) + .add(SkillGroup.middleware(locationMiddleware)) + .add(eventGroup) + .add(PtyGroup.middleware(locationMiddleware)) + .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(ReferenceGroup.middleware(locationMiddleware)) + .add(ProjectCopyGroup.middleware(locationMiddleware)) + .annotateMerge( + OpenApi.annotations({ + title: "opencode HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + .middleware(Authorization) + .middleware(SchemaErrorMiddleware) + +export const makeApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly definitions: ReadonlyArray + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => + makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware) + +export const makeDefaultApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b1eced63a2cf19447ce4688c87eefd09129fa47 --- /dev/null +++ b/packages/protocol/src/errors.ts @@ -0,0 +1,111 @@ +import { Schema } from "effect" + +export class InvalidRequestError extends Schema.TaggedErrorClass()( + "InvalidRequestError", + { + message: Schema.String, + kind: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + }, + { httpApiStatus: 400 }, +) {} + +export class UnauthorizedError extends Schema.TaggedErrorClass()( + "UnauthorizedError", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {} + +export class ConflictError extends Schema.TaggedErrorClass()( + "ConflictError", + { + message: Schema.String, + resource: Schema.optional(Schema.String), + }, + { httpApiStatus: 409 }, +) {} + +export class ServiceUnavailableError extends Schema.TaggedErrorClass()( + "ServiceUnavailableError", + { + message: Schema.String, + service: Schema.optional(Schema.String), + }, + { httpApiStatus: 503 }, +) {} + +export class UnknownError extends Schema.TaggedErrorClass()( + "UnknownError", + { + message: Schema.String, + ref: Schema.optional(Schema.String), + }, + { httpApiStatus: 500 }, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "ProviderNotFoundError", + { + providerID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class SessionNotFoundError extends Schema.TaggedErrorClass()( + "SessionNotFoundError", + { + sessionID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class MessageNotFoundError extends Schema.TaggedErrorClass()( + "MessageNotFoundError", + { + sessionID: Schema.String, + messageID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class InvalidCursorError extends Schema.TaggedErrorClass()( + "InvalidCursorError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export class PermissionNotFoundError extends Schema.TaggedErrorClass()( + "PermissionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class QuestionNotFoundError extends Schema.TaggedErrorClass()( + "QuestionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class ForbiddenError extends Schema.TaggedErrorClass()( + "ForbiddenError", + { message: Schema.String }, + { httpApiStatus: 403 }, +) {} + +export class PtyNotFoundError extends Schema.TaggedErrorClass()( + "PtyNotFoundError", + { + ptyID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d499e187f9a4b3c22d7ee4c679748d1c9db8070 --- /dev/null +++ b/packages/protocol/src/groups/agent.ts @@ -0,0 +1,20 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const AgentGroup = HttpApiGroup.make("server.agent").add( + HttpApiEndpoint.get("agent.list", "/api/agent", { + query: LocationQuery, + success: Location.response(Schema.Array(Agent.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.agent.list", + summary: "List agents", + description: "Retrieve currently registered agents.", + }), + ), +) diff --git a/packages/protocol/src/groups/command.ts b/packages/protocol/src/groups/command.ts new file mode 100644 index 0000000000000000000000000000000000000000..eac33cc292700d6f63f93f92f6ce360c5309ea85 --- /dev/null +++ b/packages/protocol/src/groups/command.ts @@ -0,0 +1,27 @@ +import { Command } from "@opencode-ai/schema/command" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const CommandGroup = HttpApiGroup.make("server.command") + .add( + HttpApiEndpoint.get("command.list", "/api/command", { + query: LocationQuery, + success: Location.response(Schema.Array(Command.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.command.list", + summary: "List commands", + description: "Retrieve currently registered commands.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "commands", + description: "Experimental command routes.", + }), + ) diff --git a/packages/protocol/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f6ce8461bb6fec9abcb0ef7da65ffa30c4b6533 --- /dev/null +++ b/packages/protocol/src/groups/credential.ts @@ -0,0 +1,37 @@ +import { Credential } from "@opencode-ai/schema/credential" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const CredentialGroup = HttpApiGroup.make("server.credential") + .add( + HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", { + params: { credentialID: Credential.ID }, + query: LocationQuery, + payload: Schema.Struct({ label: Schema.String }), + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.update", + summary: "Update credential", + description: "Update a stored credential label.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { + params: { credentialID: Credential.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.remove", + summary: "Remove credential", + description: "Remove a stored integration credential.", + }), + ), + ) diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6fc5692eae7cf631678b04eb58b9615bca163c4 --- /dev/null +++ b/packages/protocol/src/groups/event.ts @@ -0,0 +1,56 @@ +import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { Location } from "@opencode-ai/schema/location" +import type { Definition } from "@opencode-ai/schema/event" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" + +const fields = { + id: Event.ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), + location: Schema.optional(Location.Ref), +} + +const schema = >(definitions: Definitions) => + Schema.Union([ + ...definitions, + ...(definitions.some((definition) => definition.type === "server.connected") + ? [] + : [ + Schema.Struct({ + ...fields, + type: Schema.Literal("server.connected"), + data: Schema.Struct({}), + }).annotate({ identifier: "V2Event.server.connected" }), + ]), + ]).annotate({ identifier: "V2Event" }) + +const make = >(definitions: Definitions) => { + const EventSchema = schema(definitions) + return { + schema: EventSchema, + group: HttpApiGroup.make("server.event") + .add( + HttpApiEndpoint.get("event.subscribe", "/api/event", { + success: HttpApiSchema.StreamSse({ data: EventSchema }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.event.subscribe", + summary: "Subscribe to events", + description: "Subscribe to native event payloads for the server.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })), + } +} + +export const makeEventGroup = >(definitions: Definitions) => + make(definitions).group + +const event = make(EventManifest.ServerDefinitions) +export const EventGroup = event.group +export const OpenCodeEvent = event.schema +export type OpenCodeEvent = typeof OpenCodeEvent.Type +export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded diff --git a/packages/protocol/src/groups/fs.ts b/packages/protocol/src/groups/fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5fc00e02132975093a0e05ce3ee6f35f92dd021 --- /dev/null +++ b/packages/protocol/src/groups/fs.ts @@ -0,0 +1,68 @@ +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { Location } from "@opencode-ai/schema/location" +import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +const ListQuery = Schema.Struct({ + ...LocationQuery.fields, + path: RelativePath.pipe(Schema.optional), +}) + +const FindQuery = Schema.Struct({ + ...LocationQuery.fields, + query: FileSystem.FindInput.fields.query, + type: FileSystem.FindInput.fields.type, + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional), +}) + +export const FileSystemGroup = HttpApiGroup.make("server.fs") + .add( + HttpApiEndpoint.get("fs.read", "/api/fs/read/*", { + query: LocationQuery, + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.read", + summary: "Read file", + description: "Serve one file relative to the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("fs.list", "/api/fs/list", { + query: ListQuery, + success: Location.response(Schema.Array(FileSystem.Entry)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.list", + summary: "List directory", + description: "List direct children of one directory relative to the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("fs.find", "/api/fs/find", { + query: FindQuery, + success: Location.response(Schema.Array(FileSystem.Entry)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.find", + summary: "Find files", + description: "Find recursively ranked filesystem entries relative to the requested location.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "filesystem", + description: "Experimental location-scoped filesystem routes.", + }), + ) diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts new file mode 100644 index 0000000000000000000000000000000000000000..18618164f04ec8221bdadfe012d6785ac414edb3 --- /dev/null +++ b/packages/protocol/src/groups/health.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const HealthGroup = HttpApiGroup.make("server.health").add( + HttpApiEndpoint.get("health.get", "/api/health", { + success: Schema.Struct({ healthy: Schema.Literal(true) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.health.get", + summary: "Check server health", + description: "Check whether the API server is ready to accept requests.", + }), + ), +) diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts new file mode 100644 index 0000000000000000000000000000000000000000..304681d3305514b4126aa1f4b3034355b83b1ef3 --- /dev/null +++ b/packages/protocol/src/groups/integration.ts @@ -0,0 +1,130 @@ +import { Integration } from "@opencode-ai/schema/integration" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +const Inputs = Schema.Record(Schema.String, Schema.String) + +export const IntegrationGroup = HttpApiGroup.make("server.integration") + .add( + HttpApiEndpoint.get("integration.list", "/api/integration", { + query: LocationQuery, + success: Location.response(Schema.Array(Integration.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.list", + summary: "List integrations", + description: "Retrieve available integrations and their authentication methods.", + }), + ), + ) + .add( + HttpApiEndpoint.get("integration.get", "/api/integration/:integrationID", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + success: Location.response(Schema.UndefinedOr(Integration.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.get", + summary: "Get integration", + description: "Retrieve one integration and its authentication methods.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + payload: Schema.Struct({ + key: Schema.String, + label: Schema.optional(Schema.String), + }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.connect.key", + summary: "Connect with key", + description: "Run a key authentication method and store the resulting credential.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.connect.oauth", "/api/integration/:integrationID/connect/oauth", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + payload: Schema.Struct({ + methodID: Integration.MethodID, + inputs: Inputs, + label: Schema.optional(Schema.String), + }), + success: Location.response(Integration.Attempt), + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.connect.oauth", + summary: "Begin OAuth connection", + description: "Start an OAuth attempt and return the authorization details.", + }), + ), + ) + .add( + HttpApiEndpoint.get("integration.attempt.status", "/api/integration/attempt/:attemptID", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + success: Location.response(Integration.AttemptStatus), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.status", + summary: "Get OAuth attempt status", + description: "Poll the current status of an OAuth attempt.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.attempt.complete", "/api/integration/attempt/:attemptID/complete", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + payload: Schema.Struct({ code: Schema.optional(Schema.String) }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.complete", + summary: "Complete OAuth connection", + description: "Complete a code-based OAuth attempt and store the resulting credential.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("integration.attempt.cancel", "/api/integration/attempt/:attemptID", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.cancel", + summary: "Cancel OAuth connection", + description: "Cancel an OAuth attempt and release its resources.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }), + ) diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts new file mode 100644 index 0000000000000000000000000000000000000000..1752bae9bebefd0577eb075b9443d3aadabf0855 --- /dev/null +++ b/packages/protocol/src/groups/location.ts @@ -0,0 +1,42 @@ +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const LocationQuery = Schema.Struct({ + location: Schema.optional( + Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + }), + ), +}).annotate({ identifier: "LocationQuery" }) + +export const locationQueryOpenApi = OpenApi.annotations({ + transform: (operation) => { + const parameters = operation.parameters + if (!Array.isArray(parameters)) return operation + return { + ...operation, + parameters: parameters.map((parameter) => + parameter?.name === "location" && parameter?.in === "query" + ? { ...parameter, style: "deepObject", explode: true } + : parameter, + ), + } + }, +}) + +export const LocationGroup = HttpApiGroup.make("server.location").add( + HttpApiEndpoint.get("location.get", "/api/location", { + query: LocationQuery, + success: Location.Info, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.location.get", + summary: "Get location", + description: "Resolve the requested location or the server default location.", + }), + ), +) diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ace0ada994f7d5b1169411aa3f19370f23d3a81 --- /dev/null +++ b/packages/protocol/src/groups/message.ts @@ -0,0 +1,51 @@ +import { Session } from "@opencode-ai/schema/session" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors" + +export const SessionMessagesQuery = Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "SessionMessagesQuery" }) + +export const MessageGroup = HttpApiGroup.make("server.message") + .add( + HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { + params: { sessionID: Session.ID }, + query: SessionMessagesQuery, + success: Schema.Struct({ + data: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "SessionMessagesResponse" }), + error: [InvalidCursorError, SessionNotFoundError, UnknownError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get session messages", + description: + "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "messages", + description: "Experimental message routes.", + }), + ) diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..9125f9528929ba95c81ddac19bc388f19e090fbf --- /dev/null +++ b/packages/protocol/src/groups/model.ts @@ -0,0 +1,29 @@ +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ServiceUnavailableError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const ModelGroup = HttpApiGroup.make("server.model") + .add( + HttpApiEndpoint.get("model.list", "/api/model", { + query: LocationQuery, + success: Location.response(Schema.Array(Model.Info)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.model.list", + summary: "List models", + description: "Retrieve available models ordered by release date.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "models", + description: "Experimental model routes.", + }), + ) diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts new file mode 100644 index 0000000000000000000000000000000000000000..4370e18a71e829c8428e6d480d514f4bc6bba285 --- /dev/null +++ b/packages/protocol/src/groups/permission.ts @@ -0,0 +1,137 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Permission } from "@opencode-ai/schema/permission" +import { PermissionSaved } from "@opencode-ai/schema/permission-saved" +import { Project } from "@opencode-ai/schema/project" +import { Session } from "@opencode-ai/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { PermissionNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const makePermissionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApiGroup.make("server.permission") + .add( + HttpApiEndpoint.get("permission.request.list", "/api/permission/request", { + query: LocationQuery, + success: Location.response(Schema.Array(Permission.Request)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.request.list", + summary: "List pending permission requests", + description: "Retrieve pending permission requests for a location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", { + query: Schema.Struct({ projectID: Project.ID.pipe(Schema.optional) }), + success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.list", + summary: "List saved permissions", + description: "Retrieve saved permissions, optionally filtered by project.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", { + params: { id: PermissionSaved.ID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.remove", + summary: "Remove saved permission", + description: "Remove a saved permission by ID.", + }), + ), + ) + // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.post("session.permission.create", "/api/session/:sessionID/permission", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: Permission.ID.pipe(Schema.optional), + action: Permission.Request.fields.action, + resources: Permission.Request.fields.resources, + save: Permission.Request.fields.save, + metadata: Permission.Request.fields.metadata, + source: Permission.Request.fields.source, + agent: Agent.ID.pipe(Schema.optional), + }), + success: Schema.Struct({ + data: Schema.Struct({ id: Permission.ID, effect: Permission.Effect }), + }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.create", + summary: "Create permission request", + description: "Evaluate and, when approval is required, create a permission request for a session.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Permission.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.list", + summary: "List session permission requests", + description: "Retrieve pending permission requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", { + params: { sessionID: Session.ID, requestID: Permission.ID }, + success: Schema.Struct({ data: Permission.Request }), + error: [SessionNotFoundError, PermissionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.get", + summary: "Get permission request", + description: "Retrieve a pending permission request owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", { + params: { sessionID: Session.ID, requestID: Permission.ID }, + payload: Schema.Struct({ + reply: Permission.Reply, + message: Schema.String.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, PermissionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.reply", + summary: "Reply to pending permission request", + description: "Respond to a pending permission request owned by a session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) diff --git a/packages/protocol/src/groups/project-copy.ts b/packages/protocol/src/groups/project-copy.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4f0240fe33502c047d7fc74791460e44cf2d891 --- /dev/null +++ b/packages/protocol/src/groups/project-copy.ts @@ -0,0 +1,56 @@ +import { ProjectCopy } from "@opencode-ai/schema/project-copy" +import { Project } from "@opencode-ai/schema/project" +import { Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +const root = "/experimental/project/:projectID/copy" + +export class ProjectCopyError extends Schema.ErrorClass("ProjectCopyError")( + { + name: Schema.Literal("ProjectCopyError"), + data: Schema.Struct({ + message: Schema.String, + forceRequired: Schema.optional(Schema.Boolean), + }), + }, + { httpApiStatus: 400 }, +) {} + +const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"])) +const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"])) + +export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") + .add( + HttpApiEndpoint.post("projectCopy.create", root, { + params: { projectID: Project.ID }, + query: LocationQuery, + payload: CreatePayload, + success: ProjectCopy.Copy, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })), + ) + .add( + HttpApiEndpoint.delete("projectCopy.remove", root, { + params: { projectID: Project.ID }, + query: LocationQuery, + payload: RemovePayload, + success: HttpApiSchema.NoContent, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })), + ) + .add( + HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, { + params: { projectID: Project.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })), + ) + .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." })) diff --git a/packages/protocol/src/groups/provider.ts b/packages/protocol/src/groups/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..9089b1a09c7a303c8b3a3858b5943aa46ce935c6 --- /dev/null +++ b/packages/protocol/src/groups/provider.ts @@ -0,0 +1,45 @@ +import { Provider } from "@opencode-ai/schema/provider" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const ProviderGroup = HttpApiGroup.make("server.provider") + .add( + HttpApiEndpoint.get("provider.list", "/api/provider", { + query: LocationQuery, + success: Location.response(Schema.Array(Provider.Info)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.list", + summary: "List providers", + description: "Retrieve active AI providers so clients can show provider availability and configuration.", + }), + ), + ) + .add( + HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", { + params: { providerID: Provider.ID }, + query: LocationQuery, + success: Location.response(Provider.Info), + error: [ProviderNotFoundError, ServiceUnavailableError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.get", + summary: "Get provider", + description: "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "providers", + description: "Experimental provider routes.", + }), + ) diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts new file mode 100644 index 0000000000000000000000000000000000000000..a40b6c4b5332f94628c938db8e6bb404b06bd6a2 --- /dev/null +++ b/packages/protocol/src/groups/pty.ts @@ -0,0 +1,143 @@ +import { Pty } from "@opencode-ai/schema/pty" +import { PtyTicket } from "@opencode-ai/schema/pty-ticket" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ForbiddenError, PtyNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/ + +// Authorization middleware skips credential checks when this matches; the PTY connect handler +// is then responsible for consuming and validating the ticket. +export function hasPtyConnectTicketURL(url: URL) { + return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} + +export const PtyGroup = HttpApiGroup.make("server.pty") + .add( + HttpApiEndpoint.get("pty.list", "/api/pty", { + query: LocationQuery, + success: Location.response(Schema.Array(Pty.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.list", + summary: "List PTY sessions", + description: "List PTY sessions for a location, including exited sessions retained until removal.", + }), + ), + ) + .add( + HttpApiEndpoint.post("pty.create", "/api/pty", { + query: LocationQuery, + payload: Pty.CreateInput, + success: Location.response(Pty.Info), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.create", + summary: "Create PTY session", + description: "Create a pseudo-terminal session for a location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: Location.response(Pty.Info), + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.get", + summary: "Get PTY session", + description: "Get one PTY session, including its exit code once exited.", + }), + ), + ) + .add( + HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + payload: Pty.UpdateInput, + success: Location.response(Pty.Info), + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.update", + summary: "Update PTY session", + description: "Update the title or viewport size of one PTY session.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.remove", + summary: "Remove PTY session", + description: "Terminate and remove one PTY session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: Location.response(PtyTicket.ConnectToken), + error: [ForbiddenError, PtyNotFoundError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", + }), + ), + ) + .add( + // Query fields are decoded in the raw handler after the existence check so a missing + // session responds with an empty 404 before any upgrade work. + HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", { + params: { ptyID: Pty.ID }, + success: Schema.Boolean, + error: [ForbiddenError, PtyNotFoundError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.connect", + summary: "Connect to PTY session", + description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.", + transform: (operation) => ({ + ...operation, + "x-websocket": true, + parameters: [ + ...(operation.parameters ?? []), + ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ + in: "query", + name, + schema: { type: "string" }, + })), + ], + }), + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." })) diff --git a/packages/protocol/src/groups/question.ts b/packages/protocol/src/groups/question.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6d862334d8ad89dd2c3b139a3f1c3b4da7c3458 --- /dev/null +++ b/packages/protocol/src/groups/question.ts @@ -0,0 +1,84 @@ +import { Question } from "@opencode-ai/schema/question" +import { Location } from "@opencode-ai/schema/location" +import { Session } from "@opencode-ai/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { QuestionNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const makeQuestionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApiGroup.make("server.question") + .add( + HttpApiEndpoint.get("question.request.list", "/api/question/request", { + query: LocationQuery, + success: Location.response(Schema.Array(Question.Request)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.question.request.list", + summary: "List pending question requests", + description: "Retrieve pending question requests for a location.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) + // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Question.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.list", + summary: "List session question requests", + description: "Retrieve pending question requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", { + params: { sessionID: Session.ID, requestID: Question.ID }, + payload: Question.Reply, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, QuestionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.reply", + summary: "Reply to pending question request", + description: "Answer a pending question request owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", { + params: { sessionID: Session.ID, requestID: Question.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, QuestionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.reject", + summary: "Reject pending question request", + description: "Reject a pending question request owned by a session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), + ) diff --git a/packages/protocol/src/groups/reference.ts b/packages/protocol/src/groups/reference.ts new file mode 100644 index 0000000000000000000000000000000000000000..d953cd530a87c61153fab815234e6ad2ebc7c4f6 --- /dev/null +++ b/packages/protocol/src/groups/reference.ts @@ -0,0 +1,27 @@ +import { Location } from "@opencode-ai/schema/location" +import { Reference } from "@opencode-ai/schema/reference" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const ReferenceGroup = HttpApiGroup.make("server.reference") + .add( + HttpApiEndpoint.get("reference.list", "/api/reference", { + query: LocationQuery, + success: Location.response(Schema.Array(Reference.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.reference.list", + summary: "List references", + description: "List references available in the requested location.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "reference", + description: "Location-scoped project references.", + }), + ) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ce85ef79686dd5f448c9b31a9416c22608e7665 --- /dev/null +++ b/packages/protocol/src/groups/session.ts @@ -0,0 +1,379 @@ +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { PromptInput } from "@opencode-ai/schema/prompt-input" +import { Session } from "@opencode-ai/schema/session" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { + ConflictError, + InvalidCursorError, + InvalidRequestError, + MessageNotFoundError, + ServiceUnavailableError, + SessionNotFoundError, + UnknownError, +} from "../errors" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" +import { Revert } from "@opencode-ai/schema/revert" +import { SessionEvent } from "@opencode-ai/schema/session-event" + +const SessionsQueryFields = { + workspace: Workspace.ID.pipe(Schema.optional), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + search: Schema.optional(Schema.String), +} + +const SessionsDirectoryQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath, +}) + +const SessionsProjectQuery = Schema.Struct({ + ...SessionsQueryFields, + project: Project.ID, + subpath: RelativePath.pipe(Schema.optional), +}) + +const SessionsAllQuery = Schema.Struct(SessionsQueryFields) + +const withCursor = (schema: Schema.Struct) => + schema.mapFields((fields) => ({ + ...Struct.omit(fields, ["limit"]), + anchor: Session.ListAnchor, + })) + +const SessionsCursorInput = Schema.Union([ + withCursor(SessionsDirectoryQuery), + withCursor(SessionsProjectQuery), + withCursor(SessionsAllQuery), +]) +const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) +const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) +const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) +const invalidCursor = "Invalid cursor" as const + +export const SessionsCursor = Schema.String.pipe( + Schema.brand("SessionsCursor"), + statics((schema) => { + const make = schema.make.bind(schema) + return { + make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))), + parse: (input: string) => + Effect.suspend(() => { + const result = Encoding.decodeBase64UrlString(input) + return Result.isFailure(result) + ? Effect.fail(invalidCursor) + : decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor)) + }), + } + }), +) +export type SessionsCursor = typeof SessionsCursor.Type + +const SessionActive = Schema.Struct({ + type: Schema.Literal("running"), +}).annotate({ identifier: "SessionActive" }) + +const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) + +export const SessionHistoryQuery = Schema.Struct({ + limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), +}) + +const SessionsQueryCursor = SessionsCursor.annotate({ + description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", +}) + +export const SessionsQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath.pipe(Schema.optional), + project: Project.ID.pipe(Schema.optional), + subpath: RelativePath.pipe(Schema.optional), + cursor: SessionsQueryCursor.pipe(Schema.optional), +}).annotate({ identifier: "SessionsQuery" }) + +export const makeSessionGroup = (sessionLocationMiddleware: Context.Key) => + HttpApiGroup.make("server.session") + .add( + HttpApiEndpoint.get("session.list", "/api/session", { + query: SessionsQuery, + success: Schema.Struct({ + data: Schema.Array(Session.Info), + cursor: Schema.Struct({ + previous: SessionsCursor.pipe(Schema.optional), + next: SessionsCursor.pipe(Schema.optional), + }), + }).annotate({ identifier: "SessionsResponse" }), + error: [InvalidCursorError, InvalidRequestError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.list", + summary: "List sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.create", "/api/session", { + payload: Schema.Struct({ + id: Session.ID.pipe(Schema.optional), + agent: Agent.ID.pipe(Schema.optional), + model: Model.Ref.pipe(Schema.optional), + location: Location.Ref.pipe(Schema.optional), + }), + success: Schema.Struct({ data: Session.Info }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.create", + summary: "Create session", + description: "Create a session at the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.active", "/api/session/active", { + success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.active", + summary: "List active sessions", + description: + "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Session.Info }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.get", + summary: "Get session", + description: "Retrieve a session by ID.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ agent: Agent.ID }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchAgent", + summary: "Switch session agent", + description: "Switch the agent used by subsequent provider turns.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ model: Model.Ref }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchModel", + summary: "Switch session model", + description: "Switch the model used by subsequent provider turns.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), + prompt: PromptInput.Prompt, + delivery: SessionInput.Delivery.pipe(Schema.optional), + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: Schema.Struct({ data: SessionInput.Admitted }), + error: [ConflictError, SessionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send message", + description: "Durably admit one session input and schedule agent-loop execution unless resume is false.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact session", + description: "Compact a session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for session", + description: "Wait for a session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), + success: Schema.Struct({ data: Revert.State }), + error: [MessageNotFoundError, SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.revert.stage", + summary: "Stage session revert", + description: "Stage or move a reversible session boundary and optionally apply its file changes.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })), + ) + .add( + HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" }), + ), + ) + .add( + HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), + error: [SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get session context", + description: "Retrieve the active context messages for a session (all messages after the last compaction).", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", { + params: { sessionID: Session.ID }, + query: SessionHistoryQuery, + success: Schema.Struct({ + data: Schema.Array(SessionEvent.Durable), + hasMore: Schema.Boolean, + }).annotate({ identifier: "SessionHistory" }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.history", + summary: "Get session history", + description: + "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", { + params: { sessionID: Session.ID }, + query: { + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), + }, + success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.events", + summary: "Subscribe to session events", + description: "Replay durable events after an aggregate sequence, then continue with new durable events.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.interrupt", + summary: "Interrupt session execution", + description: "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { + params: { sessionID: Session.ID, messageID: SessionMessage.ID }, + success: Schema.Struct({ data: SessionMessage.Message }), + error: [SessionNotFoundError, MessageNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.message", + summary: "Get session message", + description: "Retrieve one projected message owned by the Session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "sessions", + description: "Experimental session routes.", + }), + ) diff --git a/packages/protocol/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab998a538e0dd7ec15025d81d296d74f6477eb80 --- /dev/null +++ b/packages/protocol/src/groups/skill.ts @@ -0,0 +1,27 @@ +import { Skill } from "@opencode-ai/schema/skill" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const SkillGroup = HttpApiGroup.make("server.skill") + .add( + HttpApiEndpoint.get("skill.list", "/api/skill", { + query: LocationQuery, + success: Location.response(Schema.Array(Skill.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.skill.list", + summary: "List skills", + description: "Retrieve currently registered skills.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "skills", + description: "Experimental skill routes.", + }), + ) diff --git a/packages/protocol/src/middleware/authorization.ts b/packages/protocol/src/middleware/authorization.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed1c3caf66758bb3e7dfc16ce21bee88a1f1b4b6 --- /dev/null +++ b/packages/protocol/src/middleware/authorization.ts @@ -0,0 +1,6 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { UnauthorizedError } from "../errors" + +export class Authorization extends HttpApiMiddleware.Service()("@opencode/HttpApiAuthorization", { + error: UnauthorizedError, +}) {} diff --git a/packages/protocol/src/middleware/schema-error.ts b/packages/protocol/src/middleware/schema-error.ts new file mode 100644 index 0000000000000000000000000000000000000000..635ecec197b87e072eea7b92acea5aa2b8cb1dfa --- /dev/null +++ b/packages/protocol/src/middleware/schema-error.ts @@ -0,0 +1,7 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { InvalidRequestError } from "../errors" + +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiSchemaError", + { error: InvalidRequestError }, +) {} diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2680c962e140b9002f50b743c3de87353eb008a5 --- /dev/null +++ b/packages/protocol/test/session-cursor.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session" +import { Session } from "@opencode-ai/schema/session" + +describe("SessionsCursor", () => { + test("round trips without Node globals", async () => { + const input = { + workspace: undefined, + search: "protocol", + order: "desc" as const, + anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const }, + } + const cursor = SessionsCursor.make(input) + + expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input) + }) +}) + +describe("SessionHistoryQuery", () => { + test("decodes numeric paging inputs", async () => { + const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" })) + + expect(query).toEqual({ after: 3, limit: 10 }) + }) +}) diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..adf7aec2823fe622e0fd7406b44f5a52ca4759ea --- /dev/null +++ b/packages/schema/src/agent.ts @@ -0,0 +1,38 @@ +export * as Agent from "./agent" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Model } from "./model" +import { Permission } from "./permission" +import { Provider } from "./provider" +import { PositiveInt, statics } from "./schema" + +export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export type ID = typeof ID.Type + +export const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]).annotate({ identifier: "Agent.Color" }) +export type Color = typeof Color.Type + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + model: Model.Ref.pipe(optional), + request: Provider.Request, + system: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + mode: Schema.Literals(["subagent", "primary", "all"]), + hidden: Schema.Boolean, + color: Color.pipe(optional), + steps: PositiveInt.pipe(optional), + permissions: Permission.Ruleset, +}) + .annotate({ identifier: "AgentV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (id: ID) => + schema.make({ id, request: { headers: {}, body: {} }, mode: "all", hidden: false, permissions: [] }), + })), + ) diff --git a/packages/schema/src/catalog.ts b/packages/schema/src/catalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..54abb5b1280b8e119173d6503e29fee25e49940e --- /dev/null +++ b/packages/schema/src/catalog.ts @@ -0,0 +1,6 @@ +export * as Catalog from "./catalog" + +import { define, inventory } from "./event" + +const Updated = define({ type: "catalog.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bdf3a95c501408ecca807a9b315c79c121216b7 --- /dev/null +++ b/packages/schema/src/command.ts @@ -0,0 +1,15 @@ +export * as Command from "./command" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Model } from "./model" + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + name: Schema.String, + template: Schema.String, + description: Schema.String.pipe(optional), + agent: Schema.String.pipe(optional), + model: Model.Ref.pipe(optional), + subtask: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "CommandV2.Info" }) diff --git a/packages/schema/src/connection.ts b/packages/schema/src/connection.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bacb977a870b5515d4745b16e3748c7b4d79e93 --- /dev/null +++ b/packages/schema/src/connection.ts @@ -0,0 +1,22 @@ +export * as Connection from "./connection" + +import { Schema } from "effect" +import { Credential } from "./credential" + +export interface CredentialInfo extends Schema.Schema.Type {} +export const CredentialInfo = Schema.Struct({ + type: Schema.Literal("credential"), + id: Credential.ID, + label: Schema.String, +}).annotate({ identifier: "Connection.CredentialInfo" }) + +export interface EnvInfo extends Schema.Schema.Type {} +export const EnvInfo = Schema.Struct({ + type: Schema.Literal("env"), + name: Schema.String, +}).annotate({ identifier: "Connection.EnvInfo" }) + +export const Info = Schema.Union([CredentialInfo, EnvInfo]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Connection.Info" }) +export type Info = typeof Info.Type diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts new file mode 100644 index 0000000000000000000000000000000000000000..e54e2f680b7b3179a28996078d670def3012c3dd --- /dev/null +++ b/packages/schema/src/credential.ts @@ -0,0 +1,35 @@ +export * as Credential from "./credential" + +import { Schema } from "effect" +import { optional } from "./schema" +import { IntegrationMethodID } from "./integration-id" +import { ascending } from "./identifier" +import { NonNegativeInt, statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("Credential.ID"), + statics((schema) => ({ create: () => schema.make("cred_" + ascending()) })), +) +export type ID = typeof ID.Type + +export interface OAuth extends Schema.Schema.Type {} +export const OAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + methodID: IntegrationMethodID, + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "Credential.OAuth" }) + +export interface Key extends Schema.Schema.Type {} +export const Key = Schema.Struct({ + type: Schema.Literal("key"), + key: Schema.String, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "Credential.Key" }) + +export const Value = Schema.Union([OAuth, Key]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Credential.Value" }) +export type Value = Schema.Schema.Type diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts new file mode 100644 index 0000000000000000000000000000000000000000..acdcb3e9d56859e6e888413ef3dfcc2bf2a7b34d --- /dev/null +++ b/packages/schema/src/durable-event-manifest.ts @@ -0,0 +1,15 @@ +export * as DurableEventManifest from "./durable-event-manifest" + +import { Event } from "./event" +import { SessionEvent } from "./session-event" +import { SessionV1 } from "./session-v1" + +export const SessionDurable = { + definitions: Event.durable(SessionEvent.DurableDefinitions), + schema: SessionEvent.Durable, +} as const + +export const Durable = Event.durable([ + ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined), + ...SessionEvent.DurableDefinitions, +]) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts new file mode 100644 index 0000000000000000000000000000000000000000..b681362e83bef3e725764c9b6f49488a0c92a8cb --- /dev/null +++ b/packages/schema/src/event-manifest.ts @@ -0,0 +1,84 @@ +export * as EventManifest from "./event-manifest" + +import { Catalog } from "./catalog" +import { Durable } from "./durable-event-manifest" +import { Event } from "./event" +import { FileSystem } from "./filesystem" +import { FileSystemWatcher } from "./filesystem-watcher" +import { InstallationEvent } from "./installation-event" +import { Integration } from "./integration" +import { LegacyEvent } from "./legacy-event" +import { LspEvent } from "./lsp-event" +import { McpEvent } from "./mcp-event" +import { ModelsDev } from "./models-dev" +import { Permission } from "./permission" +import { PermissionV1 } from "./permission-v1" +import { Plugin } from "./plugin" +import { Project } from "./project" +import { ProjectDirectories } from "./project-directories" +import { Pty } from "./pty" +import { Question } from "./question" +import { QuestionV1 } from "./question-v1" +import { Reference } from "./reference" +import { ServerEvent } from "./server-event" +import { SessionCompactionEvent } from "./session-compaction-event" +import { SessionEvent } from "./session-event" +import { SessionStatusEvent } from "./session-status-event" +import { SessionTodo } from "./session-todo" +import { SessionV1 } from "./session-v1" +import { TuiEvent } from "./tui-event" +import { VcsEvent } from "./vcs-event" +import { WorkspaceEvent } from "./workspace-event" +import { WorktreeEvent } from "./worktree-event" + +const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined) +const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined) + +const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions) + +const foundationDefinitions = Event.inventory( + ...ModelsDev.Event.Definitions, + ...Integration.Event.Definitions, + ...Catalog.Event.Definitions, + ...coreDefinitions, +) + +const featureDefinitions = Event.inventory( + ...FileSystem.Event.Definitions, + ...Reference.Event.Definitions, + ...Permission.Event.Definitions, + ...Plugin.Event.Definitions, + ...ProjectDirectories.Event.Definitions, + ...FileSystemWatcher.Event.Definitions, + ...Pty.Event.Definitions, + ...Question.Event.Definitions, +) + +export const ServerDefinitions = Event.inventory( + ...foundationDefinitions, + ...featureDefinitions, + ...SessionTodo.Event.Definitions, +) + +export const Definitions = Event.inventory( + ...foundationDefinitions, + ...sessionV1LiveDefinitions, + ...InstallationEvent.Definitions, + ...featureDefinitions, + ...SessionTodo.Event.Definitions, + ...LspEvent.Definitions, + ...PermissionV1.Event.Definitions, + ...TuiEvent.Definitions, + ...McpEvent.Definitions, + ...LegacyEvent.Definitions, + ...Project.Event.Definitions, + ...SessionStatusEvent.Definitions, + ...QuestionV1.Event.Definitions, + ...SessionCompactionEvent.Definitions, + ...VcsEvent.Definitions, + ...WorkspaceEvent.Definitions, + ...WorktreeEvent.Definitions, + ...ServerEvent.Definitions, +) +export const Latest = Event.latest(Definitions) +export { Durable } diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d6ec9775aa475a8c491dbdae7a40a41fd43cdf5 --- /dev/null +++ b/packages/schema/src/event.ts @@ -0,0 +1,125 @@ +export * as Event from "./event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ascending } from "./identifier" +import { Location } from "./location" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( + Schema.brand("Event.ID"), + statics((schema) => ({ create: () => schema.make("evt_" + ascending()) })), +) +export type ID = typeof ID.Type + +export type Definition< + Type extends string = string, + DataSchema extends Schema.Codec = Schema.Codec, +> = Schema.Top & { + readonly type: Type + readonly durable?: { + readonly version: number + readonly aggregate: string + } + readonly data: DataSchema +} + +export type Data = Schema.Schema.Type + +export type Payload = { + readonly id: ID + readonly type: D["type"] + readonly data: Data + readonly durable?: { + readonly aggregateID: string + readonly seq: number + readonly version: number + } + readonly location?: Location.Ref + readonly metadata?: Record +} + +export function define< + const Type extends string, + const Fields extends Readonly>>, +>(input: { + readonly type: Type + readonly durable?: { + readonly version: number + readonly aggregate: string + } + readonly schema: Fields +}) { + const data = Schema.Struct(input.schema) + return Schema.Struct({ + id: ID, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), + location: optional(Location.Ref), + data, + }) + .annotate({ identifier: input.type }) + .pipe( + statics(() => ({ + type: input.type, + ...(input.durable === undefined ? {} : { durable: input.durable }), + data, + })), + ) satisfies Definition +} + +export function inventory>(...definitions: Definitions) { + return Object.freeze(definitions) +} + +export function latest(definitions: ReadonlyArray) { + return readonlyMap( + definitions.reduce((result, definition) => { + const existing = result.get(definition.type) + if (!existing) { + result.set(definition.type, definition) + return result + } + if (definition.durable && existing.durable && definition.durable.version !== existing.durable.version) { + if (definition.durable.version > existing.durable.version) result.set(definition.type, definition) + return result + } + if (definition !== existing) throw new Error(`Duplicate latest event definition for ${definition.type}`) + return result + }, new Map()), + ) +} + +export function versionedType(type: string, version: number) { + return `${type}.${version}` +} + +export function durable>(definitions: Definitions) { + return readonlyMap( + definitions.reduce((result, definition) => { + if (!definition.durable) return result + const key = versionedType(definition.type, definition.durable.version) + if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) + result.set(key, definition) + return result + }, new Map()), + ) +} + +function readonlyMap(map: Map): ReadonlyMap { + const result: ReadonlyMap = Object.freeze({ + get size() { + return map.size + }, + entries: () => map.entries(), + forEach: (callback: (value: Value, key: Key, map: ReadonlyMap) => void, thisArg?: unknown) => + map.forEach((value, key) => callback.call(thisArg, value, key, result)), + get: (key: Key) => map.get(key), + has: (key: Key) => map.has(key), + keys: () => map.keys(), + values: () => map.values(), + [Symbol.iterator]: () => map[Symbol.iterator](), + }) + return result +} diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..45226df28ee3f809b2bd5aa2daa289ca2d565eb6 --- /dev/null +++ b/packages/schema/src/file-diff.ts @@ -0,0 +1,13 @@ +export * as FileDiff from "./file-diff" + +import { Schema } from "effect" +import { optional } from "./schema" + +export const Info = Schema.Struct({ + file: optional(Schema.String), + patch: optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, + status: optional(Schema.Literals(["added", "deleted", "modified"])), +}).annotate({ identifier: "SnapshotFileDiff" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/filesystem-watcher.ts b/packages/schema/src/filesystem-watcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e4da777cae846e639f0288e48996acf43ae71ec --- /dev/null +++ b/packages/schema/src/filesystem-watcher.ts @@ -0,0 +1,13 @@ +export * as FileSystemWatcher from "./filesystem-watcher" + +import { Schema } from "effect" +import { define, inventory } from "./event" + +const Updated = define({ + type: "file.watcher.updated", + schema: { + file: Schema.String, + event: Schema.Literals(["add", "change", "unlink"]), + }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts new file mode 100644 index 0000000000000000000000000000000000000000..1998c1b9fd688f93f81201cdec8fa8d860d8ed87 --- /dev/null +++ b/packages/schema/src/filesystem.ts @@ -0,0 +1,40 @@ +export * as FileSystem from "./filesystem" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" + +const Edited = define({ + type: "file.edited", + schema: { file: Schema.String }, +}) +export const Event = { Edited, Definitions: inventory(Edited) } + +export interface Entry extends Schema.Schema.Type {} +export const Entry = Schema.Struct({ + path: RelativePath, + type: Schema.Literals(["file", "directory"]), +}).annotate({ identifier: "FileSystem.Entry" }) + +export interface Submatch extends Schema.Schema.Type {} +export const Submatch = Schema.Struct({ + text: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, +}).annotate({ identifier: "FileSystem.Submatch" }) + +export interface Match extends Schema.Schema.Type {} +export const Match = Schema.Struct({ + entry: Entry, + line: PositiveInt, + offset: NonNegativeInt, + text: Schema.String, + submatches: Schema.Array(Submatch), +}).annotate({ identifier: "FileSystem.Match" }) + +export class FindInput extends Schema.Class("FileSystem.FindInput")({ + query: Schema.String, + type: Schema.Literals(["file", "directory"]).pipe(optional), + limit: PositiveInt.pipe(optional), +}) {} diff --git a/packages/schema/src/ide-event.ts b/packages/schema/src/ide-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca4218602143882beaff4dc3f2990497a6f025e9 --- /dev/null +++ b/packages/schema/src/ide-event.ts @@ -0,0 +1,13 @@ +export * as IdeEvent from "./ide-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Installed = Event.define({ + type: "ide.installed", + schema: { + ide: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Installed) diff --git a/packages/schema/src/identifier.ts b/packages/schema/src/identifier.ts new file mode 100644 index 0000000000000000000000000000000000000000..9812a673fb06d00a262de2a6dba6d674ecd52365 --- /dev/null +++ b/packages/schema/src/identifier.ts @@ -0,0 +1,30 @@ +const length = 26 +const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +let lastTimestamp = 0 +let counter = 0 + +export function ascending() { + return create(false) +} + +export function descending() { + return create(true) +} + +export function create(descending: boolean, timestamp = Date.now()) { + if (timestamp !== lastTimestamp) { + lastTimestamp = timestamp + counter = 0 + } + counter++ + + const current = BigInt(timestamp) * 0x1000n + BigInt(counter) + const value = descending ? ~current : current + const time = Array.from({ length: 6 }, (_, index) => + Number((value >> BigInt(40 - 8 * index)) & 0xffn) + .toString(16) + .padStart(2, "0"), + ).join("") + const bytes = crypto.getRandomValues(new Uint8Array(length - 12)) + return time + Array.from(bytes, (byte) => chars[byte % 62]).join("") +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7c8e5110f73437f34ec354155356793d101d1ec --- /dev/null +++ b/packages/schema/src/index.ts @@ -0,0 +1,28 @@ +export { Agent } from "./agent" +export { Command } from "./command" +export { Connection } from "./connection" +export { Credential } from "./credential" +export { Event } from "./event" +export { FileSystem } from "./filesystem" +export { Integration } from "./integration" +export { LLM } from "./llm" +export { Location } from "./location" +export { Model } from "./model" +export { Permission } from "./permission" +export { PermissionSaved } from "./permission-saved" +export { Project } from "./project" +export { ProjectCopy } from "./project-copy" +export { Provider } from "./provider" +export { Reference } from "./reference" +export { Revert } from "./revert" +export { Session } from "./session" +export { SessionInput } from "./session-input" +export { SessionMessage } from "./session-message" +export { Skill } from "./skill" +export { Pty } from "./pty" +export { PtyTicket } from "./pty-ticket" +export { Question } from "./question" +export { Workspace } from "./workspace" +export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt" +export { PromptInput } from "./prompt-input" +export * from "./schema" diff --git a/packages/schema/src/installation-event.ts b/packages/schema/src/installation-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..69ecf670846871ca50cbefe66de442fe0faea754 --- /dev/null +++ b/packages/schema/src/installation-event.ts @@ -0,0 +1,20 @@ +export * as InstallationEvent from "./installation-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Updated = Event.define({ + type: "installation.updated", + schema: { + version: Schema.String, + }, +}) + +export const UpdateAvailable = Event.define({ + type: "installation.update-available", + schema: { + version: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Updated, UpdateAvailable) diff --git a/packages/schema/src/integration-id.ts b/packages/schema/src/integration-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..2590dfe9f691c0410a419326e03587e554e6ac88 --- /dev/null +++ b/packages/schema/src/integration-id.ts @@ -0,0 +1,7 @@ +import { Schema } from "effect" + +export const IntegrationID = Schema.String.pipe(Schema.brand("Integration.ID")) +export type IntegrationID = typeof IntegrationID.Type + +export const IntegrationMethodID = Schema.String.pipe(Schema.brand("Integration.MethodID")) +export type IntegrationMethodID = typeof IntegrationMethodID.Type diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts new file mode 100644 index 0000000000000000000000000000000000000000..1345318da18bec5dccbee42846473ec5f1a4dc93 --- /dev/null +++ b/packages/schema/src/integration.ts @@ -0,0 +1,129 @@ +export * as Integration from "./integration" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { Connection } from "./connection" +import { ascending } from "./identifier" +import { statics } from "./schema" +import { IntegrationID, IntegrationMethodID } from "./integration-id" + +export const ID = IntegrationID +export type ID = typeof ID.Type + +export const MethodID = IntegrationMethodID +export type MethodID = typeof MethodID.Type + +export interface When extends Schema.Schema.Type {} +export const When = Schema.Struct({ + key: Schema.String, + op: Schema.Literals(["eq", "neq"]), + value: Schema.String, +}).annotate({ identifier: "Integration.When" }) + +export interface TextPrompt extends Schema.Schema.Type {} +export const TextPrompt = Schema.Struct({ + type: Schema.Literal("text"), + key: Schema.String, + message: Schema.String, + placeholder: optional(Schema.String), + when: optional(When), +}).annotate({ identifier: "Integration.TextPrompt" }) + +export interface SelectPrompt extends Schema.Schema.Type {} +export const SelectPrompt = Schema.Struct({ + type: Schema.Literal("select"), + key: Schema.String, + message: Schema.String, + options: Schema.Array( + Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: optional(Schema.String), + }), + ), + when: optional(When), +}).annotate({ identifier: "Integration.SelectPrompt" }) + +export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) +export type Prompt = typeof Prompt.Type + +export interface OAuthMethod extends Schema.Schema.Type {} +export const OAuthMethod = Schema.Struct({ + id: MethodID, + type: Schema.Literal("oauth"), + label: Schema.String, + prompts: optional(Schema.Array(Prompt)), +}).annotate({ identifier: "Integration.OAuthMethod" }) + +export interface KeyMethod extends Schema.Schema.Type {} +export const KeyMethod = Schema.Struct({ + type: Schema.Literal("key"), + label: optional(Schema.String), +}).annotate({ identifier: "Integration.KeyMethod" }) + +export interface EnvMethod extends Schema.Schema.Type {} +export const EnvMethod = Schema.Struct({ + type: Schema.Literal("env"), + names: Schema.Array(Schema.String), +}).annotate({ identifier: "Integration.EnvMethod" }) + +export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Integration.Method" }) +export type Method = typeof Method.Type + +export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" }) +export type Inputs = typeof Inputs.Type + +const Updated = define({ + type: "integration.updated", + schema: {}, +}) +const ConnectionUpdated = define({ + type: "integration.connection.updated", + schema: { integrationID: ID }, +}) +export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) } + +export interface Ref extends Schema.Schema.Type {} +export const Ref = Schema.Struct({ + id: ID, + name: Schema.String, +}).annotate({ identifier: "Integration.Ref" }) + +export class Info extends Schema.Class("Integration.Info")({ + id: ID, + name: Schema.String, + methods: Schema.Array(Method), + connections: Schema.Array(Connection.Info), +}) {} + +export const AttemptID = Schema.String.pipe( + Schema.brand("Integration.AttemptID"), + statics((schema) => ({ create: () => schema.make("con_" + ascending()) })), +) +export type AttemptID = typeof AttemptID.Type + +const AttemptTime = Schema.Struct({ + created: Schema.Number, + expires: Schema.Number, +}) + +export class Attempt extends Schema.Class("Integration.Attempt")({ + attemptID: AttemptID, + url: Schema.String, + instructions: Schema.String, + mode: Schema.Literals(["auto", "code"]), + time: AttemptTime, +}) {} + +export const AttemptStatus = Schema.Union([ + Schema.Struct({ status: Schema.Literal("pending"), time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }), +]) + .pipe(Schema.toTaggedUnion("status")) + .annotate({ identifier: "Integration.AttemptStatus" }) +export type AttemptStatus = typeof AttemptStatus.Type diff --git a/packages/schema/src/legacy-event.ts b/packages/schema/src/legacy-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7115992a6c9a2581badb751d6d9c6e0fc45dca4 --- /dev/null +++ b/packages/schema/src/legacy-event.ts @@ -0,0 +1 @@ +export * from "./v1/legacy-event" diff --git a/packages/schema/src/llm.ts b/packages/schema/src/llm.ts new file mode 100644 index 0000000000000000000000000000000000000000..44101dd87671745c56151d74417a9e6ba6ade97d --- /dev/null +++ b/packages/schema/src/llm.ts @@ -0,0 +1,28 @@ +export * as LLM from "./llm" + +import { Schema } from "effect" +import { optional } from "./schema" + +export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({ + identifier: "LLM.ProviderMetadata", +}) +export type ProviderMetadata = Schema.Schema.Type + +export interface ToolTextContent extends Schema.Schema.Type {} +export const ToolTextContent = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}).annotate({ identifier: "Tool.TextContent" }) + +export interface ToolFileContent extends Schema.Schema.Type {} +export const ToolFileContent = Schema.Struct({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: optional(Schema.String), +}).annotate({ identifier: "Tool.FileContent" }) + +export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "LLM.ToolContent" }) +export type ToolContent = Schema.Schema.Type diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts new file mode 100644 index 0000000000000000000000000000000000000000..c01ce36372a698a289b1591466d4b01b323543d9 --- /dev/null +++ b/packages/schema/src/location.ts @@ -0,0 +1,25 @@ +export * as Location from "./location" + +import { Schema } from "effect" +import { AbsolutePath, optional } from "./schema" +import { ProjectID } from "./project-id" +import { WorkspaceID } from "./workspace-id" + +export interface Ref extends Schema.Schema.Type {} +export const Ref = Schema.Struct({ + directory: AbsolutePath, + workspaceID: optional(WorkspaceID), +}).annotate({ identifier: "Location.Ref" }) + +export class Info extends Schema.Class("Location.Info")({ + directory: AbsolutePath, + workspaceID: optional(WorkspaceID), + project: Schema.Struct({ + id: ProjectID, + directory: AbsolutePath, + }), +}) {} + +export function response(data: S) { + return Schema.Struct({ location: Info, data }) +} diff --git a/packages/schema/src/lsp-event.ts b/packages/schema/src/lsp-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6908469663afaba35adca0e94f468c1b530a9aa --- /dev/null +++ b/packages/schema/src/lsp-event.ts @@ -0,0 +1,7 @@ +export * as LspEvent from "./lsp-event" + +import { Event } from "./event" + +export const Updated = Event.define({ type: "lsp.updated", schema: {} }) + +export const Definitions = Event.inventory(Updated) diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d050df92738ad5516ad00c303cb48a70e2fab2d --- /dev/null +++ b/packages/schema/src/mcp-event.ts @@ -0,0 +1,21 @@ +export * as McpEvent from "./mcp-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const ToolsChanged = Event.define({ + type: "mcp.tools.changed", + schema: { + server: Schema.String, + }, +}) + +export const BrowserOpenFailed = Event.define({ + type: "mcp.browser.open.failed", + schema: { + mcpName: Schema.String, + url: Schema.String, + }, +}) + +export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..10fa175e247bfcbaa968e41fd7d61bd57945896a --- /dev/null +++ b/packages/schema/src/model.ts @@ -0,0 +1,106 @@ +export * as Model from "./model" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Provider } from "./provider" +import { statics } from "./schema" + +export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export type ID = typeof ID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +export const Ref = Schema.Struct({ + id: ID, + providerID: Provider.ID, + variant: VariantID.pipe(optional), +}).annotate({ identifier: "Model.Ref" }) +export interface Ref extends Schema.Schema.Type {} + +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +export interface Capabilities extends Schema.Schema.Type {} +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + input: Schema.Array(Schema.String), + output: Schema.Array(Schema.String), +}).annotate({ identifier: "Model.Capabilities" }) + +export interface Cost extends Schema.Schema.Type {} +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}).annotate({ identifier: "Model.Cost" }) + +export const Api = Schema.Union([ + Schema.Struct({ + id: ID, + ...Provider.AISDK.fields, + }), + Schema.Struct({ + id: ID, + ...Provider.Native.fields, + }), +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Model.Api" }) +export type Api = typeof Api.Type + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + providerID: Provider.ID, + family: Family.pipe(optional), + name: Schema.String, + api: Api, + capabilities: Capabilities, + request: Schema.Struct({ + ...Provider.Request.fields, + variant: Schema.String.pipe(optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...Provider.Request.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: Schema.Finite, + }), + cost: Schema.Array(Cost), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + enabled: Schema.Boolean, + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(optional), + output: Schema.Int, + }), +}) + .annotate({ identifier: "ModelV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (providerID: Provider.ID, modelID: ID) => + schema.make({ + id: modelID, + providerID, + name: modelID, + api: { id: modelID, type: "native", settings: {} }, + capabilities: { tools: false, input: [], output: [] }, + request: { headers: {}, body: {} }, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 0, output: 0 }, + }), + })), + ) diff --git a/packages/schema/src/models-dev.ts b/packages/schema/src/models-dev.ts new file mode 100644 index 0000000000000000000000000000000000000000..4432bc4591ec656439ecd148300ccbb2bd0becd5 --- /dev/null +++ b/packages/schema/src/models-dev.ts @@ -0,0 +1,9 @@ +export * as ModelsDev from "./models-dev" + +import { define, inventory } from "./event" + +const Refreshed = define({ + type: "models-dev.refreshed", + schema: {}, +}) +export const Event = { Refreshed, Definitions: inventory(Refreshed) } diff --git a/packages/schema/src/permission-saved.ts b/packages/schema/src/permission-saved.ts new file mode 100644 index 0000000000000000000000000000000000000000..969a6dc25cad87bff7309ad7444f5c997ad0913b --- /dev/null +++ b/packages/schema/src/permission-saved.ts @@ -0,0 +1,20 @@ +export * as PermissionSaved from "./permission-saved" + +import { Schema } from "effect" +import { ascending } from "./identifier" +import { ProjectID } from "./project-id" +import { statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("PermissionSaved.ID"), + statics((schema) => ({ create: () => schema.make("psv_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + projectID: ProjectID, + action: Schema.String, + resource: Schema.String, +}).annotate({ identifier: "PermissionSaved.Info" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/permission-v1.ts b/packages/schema/src/permission-v1.ts new file mode 100644 index 0000000000000000000000000000000000000000..558fec83dae9d4cd92a13d2100064480f8a50cb3 --- /dev/null +++ b/packages/schema/src/permission-v1.ts @@ -0,0 +1 @@ +export * from "./v1/permission" diff --git a/packages/schema/src/permission.ts b/packages/schema/src/permission.ts new file mode 100644 index 0000000000000000000000000000000000000000..25d776284fd6bf2ce311699708239943f65416e8 --- /dev/null +++ b/packages/schema/src/permission.ts @@ -0,0 +1,65 @@ +export * as Permission from "./permission" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionV2.ID"), + statics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Source = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("tool"), + messageID: Schema.String, + callID: Schema.String, + }), +]).annotate({ identifier: "PermissionV2.Source" }) +export type Source = typeof Source.Type + +const RequestFields = { + sessionID: SessionID, + action: Schema.String, + resources: Schema.Array(Schema.String), + save: Schema.Array(Schema.String).pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), + source: Source.pipe(optional), +} + +export const Request = Schema.Struct({ + id: ID, + ...RequestFields, +}).annotate({ identifier: "PermissionV2.Request" }) +export interface Request extends Schema.Schema.Type {} + +export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export type Reply = typeof Reply.Type + +const Asked = define({ type: "permission.v2.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.v2.replied", + schema: { + sessionID: SessionID, + requestID: ID, + reply: Reply, + }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } + +export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" }) +export type Effect = typeof Effect.Type + +export interface Rule extends Schema.Schema.Type {} +export const Rule = Schema.Struct({ + action: Schema.String, + resource: Schema.String, + effect: Effect, +}).annotate({ identifier: "PermissionV2.Rule" }) + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) +export type Ruleset = typeof Ruleset.Type diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..c23f79582e37f4d2924d5b6cf869fa0ad0f5f629 --- /dev/null +++ b/packages/schema/src/plugin.ts @@ -0,0 +1,13 @@ +export * as Plugin from "./plugin" + +import { Schema } from "effect" +import { define, inventory } from "./event" + +export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export type ID = typeof ID.Type + +const Added = define({ + type: "plugin.added", + schema: { id: ID }, +}) +export const Event = { Added, Definitions: inventory(Added) } diff --git a/packages/schema/src/project-copy.ts b/packages/schema/src/project-copy.ts new file mode 100644 index 0000000000000000000000000000000000000000..850b87bcb35654013ad247ebf0fe0c0b03943739 --- /dev/null +++ b/packages/schema/src/project-copy.ts @@ -0,0 +1,30 @@ +export * as ProjectCopy from "./project-copy" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ProjectID } from "./project-id" +import { AbsolutePath } from "./schema" + +export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) +export type StrategyID = typeof StrategyID.Type + +export const CreateInput = Schema.Struct({ + projectID: ProjectID, + strategy: StrategyID, + sourceDirectory: AbsolutePath, + directory: AbsolutePath, + name: optional(Schema.String), +}).annotate({ identifier: "ProjectCopy.CreateInput" }) +export interface CreateInput extends Schema.Schema.Type {} + +export const RemoveInput = Schema.Struct({ + projectID: ProjectID, + directory: AbsolutePath, + force: Schema.Boolean, +}).annotate({ identifier: "ProjectCopy.RemoveInput" }) +export interface RemoveInput extends Schema.Schema.Type {} + +export const Copy = Schema.Struct({ + directory: AbsolutePath, +}).annotate({ identifier: "ProjectCopy.Copy" }) +export interface Copy extends Schema.Schema.Type {} diff --git a/packages/schema/src/project-directories.ts b/packages/schema/src/project-directories.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6cbde86499bcc38afa52c55f5381ce337d110b9 --- /dev/null +++ b/packages/schema/src/project-directories.ts @@ -0,0 +1,10 @@ +export * as ProjectDirectories from "./project-directories" + +import { define, inventory } from "./event" +import { Project } from "./project" + +const Updated = define({ + type: "project.directories.updated", + schema: { projectID: Project.ID }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/project-id.ts b/packages/schema/src/project-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d2f061517475e03cad1259e33af5576b174dbc6 --- /dev/null +++ b/packages/schema/src/project-id.ts @@ -0,0 +1,8 @@ +import { Schema } from "effect" +import { statics } from "./schema" + +export const ProjectID = Schema.String.pipe( + Schema.brand("Project.ID"), + statics((schema) => ({ global: schema.make("global") })), +) +export type ProjectID = typeof ProjectID.Type diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e73530a6d5624127c9086d3b6e783ca8072461b --- /dev/null +++ b/packages/schema/src/project.ts @@ -0,0 +1,44 @@ +export * as Project from "./project" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { NonNegativeInt, optional } from "./schema" +import { ProjectID } from "./project-id" + +export const ID = ProjectID +export type ID = typeof ID.Type + +export const Vcs = Schema.Literal("git").annotate({ identifier: "Project.Vcs" }) +export const Icon = Schema.Struct({ + url: optional(Schema.String), + override: optional(Schema.String), + color: optional(Schema.String), +}).annotate({ identifier: "Project.Icon" }) +export interface Icon extends Schema.Schema.Type {} +export const Commands = Schema.Struct({ + start: optional( + Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), + ), +}).annotate({ identifier: "Project.Commands" }) +export interface Commands extends Schema.Schema.Type {} +export const Time = Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + initialized: optional(NonNegativeInt), +}).annotate({ identifier: "Project.Time" }) +export interface Time extends Schema.Schema.Type {} + +export const Info = Schema.Struct({ + id: ID, + worktree: Schema.String, + vcs: optional(Vcs), + name: optional(Schema.String), + icon: optional(Icon), + commands: optional(Commands), + time: Time, + sandboxes: Schema.Array(Schema.String), +}).annotate({ identifier: "Project" }) +export interface Info extends Schema.Schema.Type {} + +const Updated = define({ type: "project.updated", schema: Info.fields }) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/prompt-input.ts b/packages/schema/src/prompt-input.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2a0d460dfdd10275fc7b7c3e13c49ec80151e64 --- /dev/null +++ b/packages/schema/src/prompt-input.ts @@ -0,0 +1,26 @@ +export * as PromptInput from "./prompt-input" + +import { Schema } from "effect" +import { AgentAttachment, Source } from "./prompt" +import { optional, statics } from "./schema" + +export interface FileAttachment extends Schema.Schema.Type {} +export const FileAttachment = Schema.Struct({ + uri: Schema.String, + name: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + source: Source.pipe(optional), +}) + .annotate({ identifier: "PromptInput.FileAttachment" }) + .pipe( + statics((schema) => ({ + create: (input: FileAttachment) => schema.make(input), + })), + ) + +export interface Prompt extends Schema.Schema.Type {} +export const Prompt = Schema.Struct({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(optional), + agents: Schema.Array(AgentAttachment).pipe(optional), +}).annotate({ identifier: "PromptInput" }) diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..376700cc7a6ccc0613da1ff470b00aeba803a74e --- /dev/null +++ b/packages/schema/src/prompt.ts @@ -0,0 +1,57 @@ +import { Schema } from "effect" +import { optional } from "./schema" +import { statics } from "./schema" + +export interface Source extends Schema.Schema.Type {} +export const Source = Schema.Struct({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}).annotate({ identifier: "Prompt.Source" }) + +export interface FileAttachment extends Schema.Schema.Type {} +export const FileAttachment = Schema.Struct({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + source: Source.pipe(optional), +}) + .annotate({ identifier: "Prompt.FileAttachment" }) + .pipe( + statics((schema) => ({ + create: (input: FileAttachment) => + schema.make({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }), + })), + ) + +export interface AgentAttachment extends Schema.Schema.Type {} +export const AgentAttachment = Schema.Struct({ + name: Schema.String, + source: Source.pipe(optional), +}).annotate({ identifier: "Prompt.AgentAttachment" }) + +export interface Prompt extends Schema.Schema.Type {} +export const Prompt = Schema.Struct({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(optional), + agents: Schema.Array(AgentAttachment).pipe(optional), +}) + .annotate({ identifier: "Prompt" }) + .pipe( + statics((schema) => ({ + equivalence: Schema.toEquivalence(schema), + fromUserMessage: (input: Pick) => + schema.make({ + text: input.text, + ...(input.files === undefined ? {} : { files: input.files }), + ...(input.agents === undefined ? {} : { agents: input.agents }), + }), + })), + ) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..51ff4b37913d568e9c59aef54e64041d9db14de8 --- /dev/null +++ b/packages/schema/src/provider.ts @@ -0,0 +1,72 @@ +export * as Provider from "./provider" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Integration } from "./integration" +import { statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("ProviderV2.ID"), + statics((schema) => ({ + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ID = typeof ID.Type + +export interface AISDK extends Schema.Schema.Type {} +export const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, + url: Schema.String.pipe(optional), + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), +}).annotate({ identifier: "Provider.AISDK" }) + +export interface Native extends Schema.Schema.Type {} +export const Native = Schema.Struct({ + type: Schema.Literal("native"), + url: Schema.String.pipe(optional), + settings: Schema.Record(Schema.String, Schema.Unknown), +}).annotate({ identifier: "Provider.Native" }) + +export const Api = Schema.Union([AISDK, Native]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Provider.Api" }) +export type Api = typeof Api.Type + +export interface Request extends Schema.Schema.Type {} +export const Request = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Json), +}).annotate({ identifier: "Provider.Request" }) + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + integrationID: Integration.ID.pipe(optional), + name: Schema.String, + disabled: Schema.Boolean.pipe(optional), + api: Api, + request: Request, +}) + .annotate({ identifier: "ProviderV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (id: ID) => + schema.make({ + id, + name: id, + api: { type: "native", settings: {} }, + request: { headers: {}, body: {} }, + }), + })), + ) diff --git a/packages/schema/src/pty-ticket.ts b/packages/schema/src/pty-ticket.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b258575a902bd62826e5f30214d236cfa36e8b5 --- /dev/null +++ b/packages/schema/src/pty-ticket.ts @@ -0,0 +1,10 @@ +export * as PtyTicket from "./pty-ticket" + +import { Schema } from "effect" +import { PositiveInt } from "./schema" + +export const ConnectToken = Schema.Struct({ + ticket: Schema.String, + expires_in: PositiveInt, +}).annotate({ identifier: "PtyTicket.ConnectToken" }) +export interface ConnectToken extends Schema.Schema.Type {} diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts new file mode 100644 index 0000000000000000000000000000000000000000..1392ece55f5c3b9bc9f846fd76de0ba51f50231e --- /dev/null +++ b/packages/schema/src/pty.ts @@ -0,0 +1,58 @@ +export * as Pty from "./pty" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { NonNegativeInt, PositiveInt, statics } from "./schema" + +const IDSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) + +export const ID = IDSchema.pipe( + statics((schema: typeof IDSchema) => { + const create = () => schema.make("pty_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + title: Schema.String, + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.String, + status: Schema.Literals(["running", "exited"]), + pid: NonNegativeInt, + exitCode: optional(NonNegativeInt), +}).annotate({ identifier: "Pty" }) +export interface Info extends Schema.Schema.Type {} + +const Created = define({ type: "pty.created", schema: { info: Info } }) +const Updated = define({ type: "pty.updated", schema: { info: Info } }) +const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } }) +const Deleted = define({ type: "pty.deleted", schema: { id: ID } }) +export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) } + +export const CreateInput = Schema.Struct({ + command: optional(Schema.String), + args: optional(Schema.Array(Schema.String)), + cwd: optional(Schema.String), + title: optional(Schema.String), + env: optional(Schema.Record(Schema.String, Schema.String)), +}) +export interface CreateInput extends Schema.Schema.Type {} + +export const UpdateInput = Schema.Struct({ + title: optional(Schema.String), + size: optional( + Schema.Struct({ + rows: PositiveInt, + cols: PositiveInt, + }), + ), +}) +export interface UpdateInput extends Schema.Schema.Type {} diff --git a/packages/schema/src/question-v1.ts b/packages/schema/src/question-v1.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bb237244ef36a5b1fe3c3851ef75c94188ba838 --- /dev/null +++ b/packages/schema/src/question-v1.ts @@ -0,0 +1 @@ +export * from "./v1/question" diff --git a/packages/schema/src/question.ts b/packages/schema/src/question.ts new file mode 100644 index 0000000000000000000000000000000000000000..aba5ebfbaf275cfb715cc0ef4b07623e165e4fe0 --- /dev/null +++ b/packages/schema/src/question.ts @@ -0,0 +1,86 @@ +export * as Question from "./question" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionV2.ID"), + statics((schema) => { + const create = () => schema.make("que_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type ID = typeof ID.Type + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), +}).annotate({ identifier: "QuestionV2.Option" }) +export interface Option extends Schema.Schema.Type {} + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }), +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.Boolean.pipe(optional).annotate({ + description: "Allow typing a custom answer (default: true)", + }), +}).annotate({ identifier: "QuestionV2.Info" }) +export interface Info extends Schema.Schema.Type {} + +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) +export interface Prompt extends Schema.Schema.Type {} + +export const Tool = Schema.Struct({ + messageID: Schema.String, + callID: Schema.String, +}).annotate({ identifier: "QuestionV2.Tool" }) +export interface Tool extends Schema.Schema.Type {} + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + tool: Tool.pipe(optional), +}).annotate({ identifier: "QuestionV2.Request" }) +export interface Request extends Schema.Schema.Type {} + +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) +export type Answer = typeof Answer.Type + +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionV2.Reply" }) +export interface Reply extends Schema.Schema.Type {} + +const Asked = define({ type: "question.v2.asked", schema: Request.fields }) +const Replied = define({ + type: "question.v2.replied", + schema: { + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), + }, +}) +const Rejected = define({ + type: "question.v2.rejected", + schema: { + sessionID: SessionID, + requestID: ID, + }, +}) +export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) } diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts new file mode 100644 index 0000000000000000000000000000000000000000..84b623a2742f9de3ea1cea82342e6f32b10cb532 --- /dev/null +++ b/packages/schema/src/reference.ts @@ -0,0 +1,39 @@ +export * as Reference from "./reference" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { AbsolutePath } from "./schema" + +const Updated = define({ type: "reference.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } + +export interface LocalSource extends Schema.Schema.Type {} +export const LocalSource = Schema.Struct({ + type: Schema.Literal("local"), + path: AbsolutePath, + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "Reference.LocalSource" }) + +export interface GitSource extends Schema.Schema.Type {} +export const GitSource = Schema.Struct({ + type: Schema.Literal("git"), + repository: Schema.String, + branch: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "Reference.GitSource" }) + +export const Source = Schema.Union([LocalSource, GitSource]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Reference.Source" }) +export type Source = typeof Source.Type + +export class Info extends Schema.Class("Reference.Info")({ + name: Schema.String, + path: AbsolutePath, + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), + source: Source, +}) {} diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts new file mode 100644 index 0000000000000000000000000000000000000000..05222d539819ee243c17cb4fd03e8ee6a1eeab69 --- /dev/null +++ b/packages/schema/src/revert.ts @@ -0,0 +1,24 @@ +export * as Revert from "./revert" + +import { Schema } from "effect" +import { optional } from "./schema" +import { NonNegativeInt, RelativePath } from "./schema" +import { SessionMessage } from "./session-message" + +export const FileDiff = Schema.Struct({ + path: RelativePath, + status: Schema.Literals(["added", "modified", "deleted"]), + additions: NonNegativeInt, + deletions: NonNegativeInt, + patch: Schema.String, +}).annotate({ identifier: "File.Diff" }) +export interface FileDiff extends Schema.Schema.Type {} + +export const State = Schema.Struct({ + messageID: SessionMessage.ID, + partID: Schema.String.pipe(optional), + snapshot: Schema.String.pipe(optional), + diff: Schema.String.pipe(optional), + files: Schema.Array(FileDiff).pipe(optional), +}).annotate({ identifier: "Revert.State" }) +export interface State extends Schema.Schema.Type {} diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..d19a39b9708cfcaeedc1d7c62503afbadfee5dd7 --- /dev/null +++ b/packages/schema/src/schema.ts @@ -0,0 +1,30 @@ +import { DateTime, Option, Schema, SchemaGetter } from "effect" + +export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) +export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + +export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) +export type RelativePath = typeof RelativePath.Type + +export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) +export type AbsolutePath = typeof AbsolutePath.Type + +export const optional = (schema: S) => + Schema.optionalKey(schema).pipe( + Schema.decodeTo(Schema.optional(Schema.toType(schema)), { + decode: SchemaGetter.passthrough({ strict: false }), + encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), + }), + ) + +export const statics = + >(methods: (schema: S) => M) => + (schema: S): S & M => + Object.assign(schema, methods(schema)) + +export const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) diff --git a/packages/schema/src/server-event.ts b/packages/schema/src/server-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d8ac2d470fff0e2e946342af347888ccb1b3bc0 --- /dev/null +++ b/packages/schema/src/server-event.ts @@ -0,0 +1,8 @@ +export * as ServerEvent from "./server-event" + +import { Event } from "./event" + +export const Connected = Event.define({ type: "server.connected", schema: {} }) +export const Disposed = Event.define({ type: "global.disposed", schema: {} }) + +export const Definitions = Event.inventory(Connected, Disposed) diff --git a/packages/schema/src/session-compaction-event.ts b/packages/schema/src/session-compaction-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed1169ea6757ee981fc425219b2e82442db7c8b0 --- /dev/null +++ b/packages/schema/src/session-compaction-event.ts @@ -0,0 +1,13 @@ +export * as SessionCompactionEvent from "./session-compaction-event" + +import { Event } from "./event" +import { SessionID } from "./session-id" + +export const Compacted = Event.define({ + type: "session.compacted", + schema: { + sessionID: SessionID, + }, +}) + +export const Definitions = Event.inventory(Compacted) diff --git a/packages/schema/src/session-delivery.ts b/packages/schema/src/session-delivery.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b678dabf9f910b173f2b2cfddebacbd11922264 --- /dev/null +++ b/packages/schema/src/session-delivery.ts @@ -0,0 +1,6 @@ +export * as SessionDelivery from "./session-delivery" + +import { Schema } from "effect" + +export const Delivery = Schema.Literals(["steer", "queue"]) +export type Delivery = typeof Delivery.Type diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a559c3e38a401218ac36e3f79051172df4dbe3d --- /dev/null +++ b/packages/schema/src/session-event.ts @@ -0,0 +1,521 @@ +export * as SessionEvent from "./session-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { ProviderMetadata, ToolContent } from "./llm" +import { Delivery } from "./session-delivery" +import { Model } from "./model" +import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema" +import { FileAttachment, Prompt } from "./prompt" +import { SessionID } from "./session-id" +import { Location } from "./location" +import { SessionMessage } from "./session-message" +import { Revert } from "./revert" + +export { FileAttachment } + +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export interface Source extends Schema.Schema.Type {} + +const Base = { + timestamp: DateTimeUtcFromMillis, + sessionID: SessionID, +} +const PromptFields = { + ...Base, + messageID: SessionMessage.ID, + prompt: Prompt, + delivery: Delivery, +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const +const stepSettlementOptions = { + durable: { + aggregate: "sessionID", + version: 2, + }, +} as const + +export const UnknownError = SessionMessage.UnknownError +export type UnknownError = SessionMessage.UnknownError + +export const AgentSwitched = Event.define({ + type: "session.next.agent.switched", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + agent: Schema.String, + }, +}) +export type AgentSwitched = typeof AgentSwitched.Type + +export const ModelSwitched = Event.define({ + type: "session.next.model.switched", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + model: Model.Ref, + }, +}) +export type ModelSwitched = typeof ModelSwitched.Type + +export const Moved = Event.define({ + type: "session.next.moved", + ...options, + schema: { + ...Base, + location: Location.Ref, + subdirectory: RelativePath.pipe(optional), + }, +}) +export type Moved = typeof Moved.Type + +export const Prompted = Event.define({ + type: "session.next.prompted", + ...options, + schema: PromptFields, +}) +export type Prompted = typeof Prompted.Type + +export const PromptAdmitted = Event.define({ + type: "session.next.prompt.admitted", + ...options, + schema: PromptFields, +}) +export type PromptAdmitted = typeof PromptAdmitted.Type + +export const ContextUpdated = Event.define({ + type: "session.next.context.updated", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, +}) +export type ContextUpdated = typeof ContextUpdated.Type + +export const Synthetic = Event.define({ + type: "session.next.synthetic", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, +}) +export type Synthetic = typeof Synthetic.Type + +export namespace Shell { + export const Started = Event.define({ + type: "session.next.shell.started", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = typeof Started.Type + + export const Ended = Event.define({ + type: "session.next.shell.ended", + ...options, + schema: { + ...Base, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Step { + export const Started = Event.define({ + type: "session.next.step.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + agent: Schema.String, + model: Model.Ref, + snapshot: Schema.String.pipe(optional), + }, + }) + export type Started = typeof Started.Type + + export const Ended = Event.define({ + type: "session.next.step.ended", + ...stepSettlementOptions, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + finish: Schema.String, + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + snapshot: Schema.String.pipe(optional), + files: Schema.Array(RelativePath).pipe(optional), + }, + }) + export type Ended = typeof Ended.Type + + export const Failed = Event.define({ + type: "session.next.step.failed", + ...stepSettlementOptions, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + error: UnknownError, + }, + }) + export type Failed = typeof Failed.Type +} + +export namespace Text { + export const Started = Event.define({ + type: "session.next.text.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. + export const Delta = Event.define({ + type: "session.next.text.delta", + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.text.ended", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + text: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Reasoning { + export const Started = Event.define({ + type: "session.next.reasoning.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. + export const Delta = Event.define({ + type: "session.next.reasoning.delta", + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.reasoning.ended", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + text: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Tool { + const ToolBase = { + ...Base, + assistantMessageID: SessionMessage.ID, + callID: Schema.String, + } + + export namespace Input { + export const Started = Event.define({ + type: "session.next.tool.input.started", + ...options, + schema: { + ...ToolBase, + name: Schema.String, + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. + export const Delta = Event.define({ + type: "session.next.tool.input.delta", + schema: { + ...ToolBase, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.tool.input.ended", + ...options, + schema: { + ...ToolBase, + text: Schema.String, + }, + }) + export type Ended = typeof Ended.Type + } + + export const Called = Event.define({ + type: "session.next.tool.called", + ...options, + schema: { + ...ToolBase, + tool: Schema.String, + input: Schema.Record(Schema.String, Schema.Unknown), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Called = typeof Called.Type + + /** + * Replayable bounded running-tool state. Tools should checkpoint semantic + * transitions or at a bounded cadence, not persist every stdout/stderr chunk. + */ + export const Progress = Event.define({ + type: "session.next.tool.progress", + ...options, + schema: { + ...ToolBase, + structured: Schema.Record(Schema.String, Schema.Unknown), + content: Schema.Array(ToolContent), + }, + }) + export type Progress = typeof Progress.Type + + export const Success = Event.define({ + type: "session.next.tool.success", + ...options, + schema: { + ...ToolBase, + structured: Schema.Record(Schema.String, Schema.Unknown), + content: Schema.Array(ToolContent), + outputPaths: Schema.Array(Schema.String).pipe(optional), + result: Schema.Unknown.pipe(optional), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Success = typeof Success.Type + + export const Failed = Event.define({ + type: "session.next.tool.failed", + ...options, + schema: { + ...ToolBase, + error: UnknownError, + result: Schema.Unknown.pipe(optional), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Failed = typeof Failed.Type +} + +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: Schema.Finite.pipe(optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional), + responseBody: Schema.String.pipe(optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export interface RetryError extends Schema.Schema.Type {} + +export const Retried = Event.define({ + type: "session.next.retried", + ...options, + schema: { + ...Base, + attempt: Schema.Finite, + error: RetryError, + }, +}) +export type Retried = typeof Retried.Type + +export namespace Compaction { + export const Started = Event.define({ + type: "session.next.compaction.started", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), + }, + }) + export type Started = typeof Started.Type + + export const Delta = Event.define({ + type: "session.next.compaction.delta", + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.compaction.ended", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + reason: Started.data.fields.reason, + text: Schema.String, + recent: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace RevertEvent { + export const Staged = Event.define({ + type: "session.next.revert.staged", + ...options, + schema: { ...Base, revert: Revert.State }, + }) + export const Cleared = Event.define({ type: "session.next.revert.cleared", ...options, schema: Base }) + export const Committed = Event.define({ + type: "session.next.revert.committed", + ...options, + schema: { ...Base, messageID: SessionMessage.ID }, + }) +} + +export const DurableDefinitions = Event.inventory( + AgentSwitched, + ModelSwitched, + Moved, + Prompted, + PromptAdmitted, + ContextUpdated, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Ended, + Tool.Input.Started, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Reasoning.Started, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Ended, + RevertEvent.Staged, + RevertEvent.Cleared, + RevertEvent.Committed, +) + +export const Definitions = Event.inventory( + AgentSwitched, + ModelSwitched, + Moved, + Prompted, + PromptAdmitted, + ContextUpdated, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Delta, + Text.Ended, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, + RevertEvent.Staged, + RevertEvent.Cleared, + RevertEvent.Committed, +) + +export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "SessionDurableEvent" }) +export type DurableEvent = typeof Durable.Type + +export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) +export type Event = typeof All.Type +export type Type = Event["type"] diff --git a/packages/schema/src/session-id.ts b/packages/schema/src/session-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..3603ebe703b7b350018626a74e794f94e33079a3 --- /dev/null +++ b/packages/schema/src/session-id.ts @@ -0,0 +1,15 @@ +import { Schema } from "effect" +import { descending } from "./identifier" +import { statics } from "./schema" + +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( + Schema.brand("SessionID"), + statics((schema) => { + const create = () => schema.make("ses_" + descending()) + return { + create, + descending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type SessionID = typeof SessionID.Type diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts new file mode 100644 index 0000000000000000000000000000000000000000..40babac105f66671baeb59679e275f6536a5ae26 --- /dev/null +++ b/packages/schema/src/session-input.ts @@ -0,0 +1,23 @@ +export * as SessionInput from "./session-input" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Prompt } from "./prompt" +import { DateTimeUtcFromMillis, NonNegativeInt } from "./schema" +import { SessionDelivery } from "./session-delivery" +import { SessionID } from "./session-id" +import { SessionMessage } from "./session-message" + +export const Delivery = SessionDelivery.Delivery +export type Delivery = SessionDelivery.Delivery + +export interface Admitted extends Schema.Schema.Type {} +export const Admitted = Schema.Struct({ + admittedSeq: NonNegativeInt, + id: SessionMessage.ID, + sessionID: SessionID, + prompt: Prompt, + delivery: Delivery, + timeCreated: DateTimeUtcFromMillis, + promotedSeq: NonNegativeInt.pipe(optional), +}).annotate({ identifier: "SessionInput.Admitted" }) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..58ff532063a0232f91d278ec0e8773ee906c7460 --- /dev/null +++ b/packages/schema/src/session-message.ts @@ -0,0 +1,213 @@ +export * as SessionMessage from "./session-message" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ProviderMetadata, ToolContent } from "./llm" +import { Model } from "./model" +import { FileAttachment, Prompt } from "./prompt" +import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema" +import { SessionID } from "./session-id" +import { ascending } from "./identifier" + +export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( + Schema.brand("Session.Message.ID"), + statics((schema) => ({ create: () => schema.make("msg_" + ascending()) })), +) +export type ID = typeof ID.Type + +export interface UnknownError extends Schema.Schema.Type {} +export const UnknownError = Schema.Struct({ + type: Schema.Literal("unknown"), + message: Schema.String, +}).annotate({ identifier: "Session.Error.Unknown" }) + +const Base = { + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), + time: Schema.Struct({ created: DateTimeUtcFromMillis }), +} + +export interface AgentSwitched extends Schema.Schema.Type {} +export const AgentSwitched = Schema.Struct({ + ...Base, + type: Schema.Literal("agent-switched"), + agent: Schema.String, +}).annotate({ identifier: "Session.Message.AgentSwitched" }) + +export interface ModelSwitched extends Schema.Schema.Type {} +export const ModelSwitched = Schema.Struct({ + ...Base, + type: Schema.Literal("model-switched"), + model: Model.Ref, +}).annotate({ identifier: "Session.Message.ModelSwitched" }) + +export interface User extends Schema.Schema.Type {} +export const User = Schema.Struct({ + ...Base, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, + type: Schema.Literal("user"), +}).annotate({ identifier: "Session.Message.User" }) + +export interface Synthetic extends Schema.Schema.Type {} +export const Synthetic = Schema.Struct({ + ...Base, + sessionID: SessionID, + text: Schema.String, + type: Schema.Literal("synthetic"), +}).annotate({ identifier: "Session.Message.Synthetic" }) + +export interface System extends Schema.Schema.Type {} +export const System = Schema.Struct({ + ...Base, + type: Schema.Literal("system"), + text: Schema.String, +}).annotate({ identifier: "Session.Message.System" }) + +export interface Shell extends Schema.Schema.Type {} +export const Shell = Schema.Struct({ + ...Base, + type: Schema.Literal("shell"), + callID: Schema.String, + command: Schema.String, + output: Schema.String, + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Shell" }) + +export interface ToolStatePending extends Schema.Schema.Type {} +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.String, +}).annotate({ identifier: "Session.Message.ToolState.Pending" }) + +export interface ToolStateRunning extends Schema.Schema.Type {} +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Unknown), + structured: Schema.Record(Schema.String, Schema.Unknown), + content: ToolContent.pipe(Schema.Array), +}).annotate({ identifier: "Session.Message.ToolState.Running" }) + +export interface ToolStateCompleted extends Schema.Schema.Type {} +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Unknown), + attachments: FileAttachment.pipe(Schema.Array, optional), + content: ToolContent.pipe(Schema.Array), + outputPaths: Schema.Array(Schema.String).pipe(optional), + structured: Schema.Record(Schema.String, Schema.Unknown), + result: Schema.Unknown.pipe(optional), +}).annotate({ identifier: "Session.Message.ToolState.Completed" }) + +export interface ToolStateError extends Schema.Schema.Type {} +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Unknown), + content: ToolContent.pipe(Schema.Array), + structured: Schema.Record(Schema.String, Schema.Unknown), + error: UnknownError, + result: Schema.Unknown.pipe(optional), +}).annotate({ identifier: "Session.Message.ToolState.Error" }) + +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export interface AssistantTool extends Schema.Schema.Type {} +export const AssistantTool = Schema.Struct({ + type: Schema.Literal("tool"), + id: Schema.String, + name: Schema.String, + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + resultMetadata: ProviderMetadata.pipe(optional), + }).pipe(optional), + state: ToolState, + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + ran: DateTimeUtcFromMillis.pipe(optional), + completed: DateTimeUtcFromMillis.pipe(optional), + pruned: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Assistant.Tool" }) + +export interface AssistantText extends Schema.Schema.Type {} +export const AssistantText = Schema.Struct({ + type: Schema.Literal("text"), + id: Schema.String, + text: Schema.String, +}).annotate({ identifier: "Session.Message.Assistant.Text" }) + +export interface AssistantReasoning extends Schema.Schema.Type {} +export const AssistantReasoning = Schema.Struct({ + type: Schema.Literal("reasoning"), + id: Schema.String, + text: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }).pipe(optional), +}).annotate({ identifier: "Session.Message.Assistant.Reasoning" }) + +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) +export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool + +export interface Assistant extends Schema.Schema.Type {} +export const Assistant = Schema.Struct({ + ...Base, + type: Schema.Literal("assistant"), + agent: Schema.String, + model: Model.Ref, + content: AssistantContent.pipe(Schema.Array), + snapshot: Schema.Struct({ + start: Schema.String.pipe(optional), + end: Schema.String.pipe(optional), + files: Schema.Array(RelativePath).pipe(optional), + }).pipe(optional), + finish: Schema.String.pipe(optional), + cost: Schema.Finite.pipe(optional), + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }), + }).pipe(optional), + error: UnknownError.pipe(optional), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Assistant" }) + +export interface Compaction extends Schema.Schema.Type {} +export const Compaction = Schema.Struct({ + type: Schema.Literal("compaction"), + reason: Schema.Literals(["auto", "manual"]), + summary: Schema.String, + recent: Schema.String, + ...Base, +}).annotate({ identifier: "Session.Message.Compaction" }) + +export const Message = Schema.Union([ + AgentSwitched, + ModelSwitched, + User, + Synthetic, + System, + Shell, + Assistant, + Compaction, +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Session.Message" }) +export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Shell | Assistant | Compaction +export type Type = Message["type"] diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6a3022bcb1d87dfe5c5d07be65588e225f6fe5b --- /dev/null +++ b/packages/schema/src/session-status-event.ts @@ -0,0 +1,51 @@ +export * as SessionStatusEvent from "./session-status-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { NonNegativeInt } from "./schema" +import { SessionID } from "./session-id" + +export const Info = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("idle"), + }), + Schema.Struct({ + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + message: Schema.String, + action: optional( + Schema.Struct({ + reason: Schema.String, + provider: Schema.String, + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: optional(Schema.String), + }), + ), + next: NonNegativeInt, + }), + Schema.Struct({ + type: Schema.Literal("busy"), + }), +]).annotate({ identifier: "SessionStatus" }) +export type Info = Schema.Schema.Type + +export const Status = Event.define({ + type: "session.status", + schema: { + sessionID: SessionID, + status: Info, + }, +}) + +// deprecated +export const Idle = Event.define({ + type: "session.idle", + schema: { + sessionID: SessionID, + }, +}) + +export const Definitions = Event.inventory(Status, Idle) diff --git a/packages/schema/src/session-todo.ts b/packages/schema/src/session-todo.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f6a72bf000617eb14ee8bedebd9dc0800260510 --- /dev/null +++ b/packages/schema/src/session-todo.ts @@ -0,0 +1,25 @@ +export * as SessionTodo from "./session-todo" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { SessionID } from "./session-id" + +export const Info = Schema.Struct({ + content: Schema.String.annotate({ description: "Brief description of the task" }), + status: Schema.String.annotate({ + description: "Current status of the task: pending, in_progress, completed, cancelled", + }), + priority: Schema.String.annotate({ + description: "Priority level of the task: high, medium, low", + }), +}).annotate({ identifier: "Todo" }) +export interface Info extends Schema.Schema.Type {} + +const Updated = define({ + type: "todo.updated", + schema: { + sessionID: SessionID, + todos: Schema.Array(Info), + }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/session-v1.ts b/packages/schema/src/session-v1.ts new file mode 100644 index 0000000000000000000000000000000000000000..22455dd412539edb8ea762c757d52d032ac35304 --- /dev/null +++ b/packages/schema/src/session-v1.ts @@ -0,0 +1 @@ +export * from "./v1/session" diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..937705eeb2f4dd3cb6ba3646a676179e0866e0d0 --- /dev/null +++ b/packages/schema/src/session.ts @@ -0,0 +1,51 @@ +export * as Session from "./session" + +import { Schema } from "effect" +import { Agent } from "./agent" +import { Location } from "./location" +import { Model } from "./model" +import { Project } from "./project" +import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema" +import { SessionEvent } from "./session-event" +import { SessionID } from "./session-id" +import { Revert } from "./revert" + +export const ID = SessionID +export type ID = SessionID + +export const Event = SessionEvent + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + parentID: ID.pipe(optional), + projectID: Project.ID, + agent: Agent.ID.pipe(optional), + model: Model.Ref.pipe(optional), + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + updated: DateTimeUtcFromMillis, + archived: DateTimeUtcFromMillis.pipe(optional), + }), + title: Schema.String, + location: Location.Ref, + subpath: RelativePath.pipe(optional), + revert: Revert.State.pipe(optional), +}).annotate({ identifier: "SessionV2.Info" }) + +export const ListAnchor = Schema.Struct({ + id: ID, + time: Schema.Finite, + direction: Schema.Literals(["previous", "next"]), +}).annotate({ identifier: "Session.ListAnchor" }) +export interface ListAnchor extends Schema.Schema.Type {} diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec299180ed0b5cedcf33e892a8b27a6382c423e4 --- /dev/null +++ b/packages/schema/src/skill.ts @@ -0,0 +1,55 @@ +export * as Skill from "./skill" + +import { Schema } from "effect" +import { optional } from "./schema" +import { AbsolutePath } from "./schema" + +export interface DirectorySource extends Schema.Schema.Type {} +export const DirectorySource = Schema.Struct({ + type: Schema.Literal("directory"), + path: AbsolutePath, +}).annotate({ identifier: "SkillV2.DirectorySource" }) + +export interface UrlSource extends Schema.Schema.Type {} +export const UrlSource = Schema.Struct({ + type: Schema.Literal("url"), + url: Schema.String, +}).annotate({ identifier: "SkillV2.UrlSource" }) + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.String.pipe(optional), + slash: Schema.Boolean.pipe(optional), + location: AbsolutePath, + content: Schema.String, +}).annotate({ identifier: "SkillV2.Info" }) + +export interface EmbeddedSource extends Schema.Schema.Type {} +export const EmbeddedSource = Schema.Struct({ + type: Schema.Literal("embedded"), + skill: Schema.suspend(() => Info), +}).annotate({ identifier: "SkillV2.EmbeddedSource" }) + +export type Source = DirectorySource | UrlSource | EmbeddedSource +export const Source = Object.assign( + Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "SkillV2.Source" }), + ), + { + equals: (a: Source, b: Source) => { + if (a.type !== b.type) return false + if (a.type === "directory" && b.type === "directory") return a.path === b.path + if (a.type === "url" && b.type === "url") return a.url === b.url + if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name + return false + }, + key: (source: Source) => + source.type === "directory" + ? `directory:${source.path}` + : source.type === "url" + ? `url:${source.url}` + : `embedded:${source.skill.name}`, + }, +) diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..800094e61e8fcea5d93e72fb4fe5e99baa48ff84 --- /dev/null +++ b/packages/schema/src/tui-event.ts @@ -0,0 +1,59 @@ +export * as TuiEvent from "./tui-event" + +import { Effect, Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { PositiveInt } from "./schema" +import { SessionID } from "./session-id" + +const DEFAULT_TOAST_DURATION = 5000 + +export const PromptAppend = Event.define({ type: "tui.prompt.append", schema: { text: Schema.String } }) + +export const CommandExecute = Event.define({ + type: "tui.command.execute", + schema: { + command: Schema.Union([ + Schema.Literals([ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle", + ]), + Schema.String, + ]), + }, +}) + +export const ToastShow = Event.define({ + type: "tui.toast.show", + schema: { + title: optional(Schema.String), + message: Schema.String, + variant: Schema.Literals(["info", "success", "warning", "error"]), + duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ + description: "Duration in milliseconds", + }), + }, +}) + +export const SessionSelect = Event.define({ + type: "tui.session.select", + schema: { + sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), + }, +}) + +export const Definitions = Event.inventory(PromptAppend, CommandExecute, ToastShow, SessionSelect) diff --git a/packages/schema/src/v1/legacy-event.ts b/packages/schema/src/v1/legacy-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..d87902dad57dc3078b5ed42c6888a37c5037dd5f --- /dev/null +++ b/packages/schema/src/v1/legacy-event.ts @@ -0,0 +1,18 @@ +export * as LegacyEvent from "./legacy-event" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { SessionID } from "../session-id" +import { SessionV1 } from "./session" + +export const CommandExecuted = define({ + type: "command.executed", + schema: { + name: Schema.String, + sessionID: SessionID, + arguments: Schema.String, + messageID: SessionV1.MessageID, + }, +}) + +export const Definitions = inventory(CommandExecuted) diff --git a/packages/schema/src/v1/permission.ts b/packages/schema/src/v1/permission.ts new file mode 100644 index 0000000000000000000000000000000000000000..9096f4f24982920956fc813ffb2901fea932f26e --- /dev/null +++ b/packages/schema/src/v1/permission.ts @@ -0,0 +1,66 @@ +export * as PermissionV1 from "./permission" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { ascending } from "../identifier" +import { Project } from "../project" +import { statics } from "../schema" +import { SessionID } from "../session-id" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) +export type Action = typeof Action.Type + +export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action }).annotate({ + identifier: "PermissionRule", +}) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) +export type Ruleset = typeof Ruleset.Type + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional(Schema.Struct({ messageID: Schema.String, callID: Schema.String })), +}).annotate({ identifier: "PermissionRequest" }) +export type Request = typeof Request.Type + +export const Reply = Schema.Literals(["once", "always", "reject"]) +export type Reply = typeof Reply.Type + +export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.optional(Schema.String) }).annotate({ + identifier: "PermissionReplyBody", +}) +export type ReplyBody = typeof ReplyBody.Type + +export const Approval = Schema.Struct({ projectID: Project.ID, patterns: Schema.Array(Schema.String) }).annotate({ + identifier: "PermissionApproval", +}) +export type Approval = typeof Approval.Type + +export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(ID), ruleset: Ruleset }).annotate({ + identifier: "PermissionAskInput", +}) +export type AskInput = typeof AskInput.Type + +export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }).annotate({ + identifier: "PermissionReplyInput", +}) +export type ReplyInput = typeof ReplyInput.Type + +const Asked = define({ type: "permission.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.replied", + schema: { sessionID: SessionID, requestID: ID, reply: Reply }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } diff --git a/packages/schema/src/v1/question.ts b/packages/schema/src/v1/question.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5669964726de81e207c27206081605863622f73 --- /dev/null +++ b/packages/schema/src/v1/question.ts @@ -0,0 +1,66 @@ +export * as QuestionV1 from "./question" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { ascending } from "../identifier" +import { statics } from "../schema" +import { SessionID } from "../session-id" +import { SessionV1 } from "./session" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })), +) + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), +}).annotate({ identifier: "QuestionOption" }) + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }), +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)" }), +}).annotate({ identifier: "QuestionInfo" }) +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) +export const Tool = Schema.Struct({ messageID: SessionV1.MessageID, callID: Schema.String }).annotate({ + identifier: "QuestionTool", +}) +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + tool: Schema.optional(Tool), +}).annotate({ identifier: "QuestionRequest" }) +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionReply" }) +export const Replied = Schema.Struct({ + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), +}).annotate({ + identifier: "QuestionReplied", +}) +export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).annotate({ + identifier: "QuestionRejected", +}) + +const Asked = define({ type: "question.asked", schema: Request.fields }) +const RepliedEvent = define({ type: "question.replied", schema: Replied.fields }) +const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields }) +export const Event = { + Asked, + Replied: RepliedEvent, + Rejected: RejectedEvent, + Definitions: inventory(Asked, RepliedEvent, RejectedEvent), +} diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..75e9282f117cb25078b087661434fcf5e6660620 --- /dev/null +++ b/packages/schema/src/v1/session.ts @@ -0,0 +1,676 @@ +export * as SessionV1 from "./session" + +import { Effect, Schema, Types } from "effect" +import { define, inventory } from "../event" +import { FileDiff } from "../file-diff" +import { Project } from "../project" +import { Provider } from "../provider" +import { Model } from "../model" +import { NonNegativeInt, optional, statics } from "../schema" +import { ascending } from "../identifier" +import { SessionID } from "../session-id" +import { WorkspaceID } from "../workspace-id" +import { PermissionV1 } from "./permission" + +const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) + +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( + Schema.brand("MessageID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + ascending()) })), +) +export type MessageID = typeof MessageID.Type + +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( + Schema.brand("PartID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + ascending()) })), +) +export type PartID = typeof PartID.Type + +const namedError = (name: Name, fields: Fields) => { + const schema = Schema.Struct({ name: Schema.Literal(name), data: Schema.Struct(fields) }).annotate({ + identifier: name, + }) + return { Schema: schema, EffectSchema: schema } +} + +export const OutputLengthError = namedError("MessageOutputLengthError", {}) + +export const AuthError = namedError("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) + +export const AbortedError = namedError("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = namedError("StructuredOutputError", { + message: Schema.String, + retries: NonNegativeInt, +}) +export const APIError = namedError("APIError", { + message: Schema.String, + statusCode: Schema.optional(NonNegativeInt), + isRetryable: Schema.Boolean, + responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), + responseBody: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +export type APIError = Schema.Schema.Type +export const ContextOverflowError = namedError("ContextOverflowError", { + message: Schema.String, + responseBody: Schema.optional(Schema.String), +}) +export const ContentFilterError = namedError("ContentFilterError", { + message: Schema.String, +}) + +export class OutputFormatText extends Schema.Class("OutputFormatText")({ + type: Schema.Literal("text"), +}) {} + +export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ + type: Schema.Literal("json_schema"), + schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), + retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), +}) {} + +export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ + discriminator: "type", + identifier: "OutputFormat", +}) +export type OutputFormat = Schema.Schema.Type + +const partBase = { + id: PartID, + sessionID: SessionID, + messageID: MessageID, +} + +export const SnapshotPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("snapshot"), + snapshot: Schema.String, +}).annotate({ identifier: "SnapshotPart" }) +export type SnapshotPart = Types.DeepMutable> + +export const PatchPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("patch"), + hash: Schema.String, + files: Schema.Array(Schema.String), +}).annotate({ identifier: "PatchPart" }) +export type PatchPart = Types.DeepMutable> + +export const TextPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPart" }) +export type TextPart = Types.DeepMutable> + +export const ReasoningPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("reasoning"), + text: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), +}).annotate({ identifier: "ReasoningPart" }) +export type ReasoningPart = Types.DeepMutable> + +const filePartSourceBase = { + text: Schema.Struct({ + value: Schema.String, + start: Schema.Finite, + end: Schema.Finite, + }).annotate({ identifier: "FilePartSourceText" }), +} + +export const Range = Schema.Struct({ + start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), + end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), +}).annotate({ identifier: "Range" }) +export type Range = typeof Range.Type + +export const FileSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("file"), + path: Schema.String, +}).annotate({ identifier: "FileSource" }) + +export const SymbolSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("symbol"), + path: Schema.String, + range: Range, + name: Schema.String, + kind: NonNegativeInt, +}).annotate({ identifier: "SymbolSource" }) + +export const ResourceSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("resource"), + clientName: Schema.String, + uri: Schema.String, +}).annotate({ identifier: "ResourceSource" }) + +export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ + discriminator: "type", + identifier: "FilePartSource", +}) + +export const FilePart = Schema.Struct({ + ...partBase, + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePart" }) +export type FilePart = Types.DeepMutable> + +export const AgentPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPart" }) +export type AgentPart = Types.DeepMutable> + +export const CompactionPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("compaction"), + auto: Schema.Boolean, + overflow: Schema.optional(Schema.Boolean), + tail_start_id: Schema.optional(MessageID), +}).annotate({ identifier: "CompactionPart" }) +export type CompactionPart = Types.DeepMutable> + +export const SubtaskPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPart" }) +export type SubtaskPart = Types.DeepMutable> + +export const RetryPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + error: APIError.EffectSchema, + time: Schema.Struct({ + created: NonNegativeInt, + }), +}).annotate({ identifier: "RetryPart" }) +export type RetryPart = Omit>, "error"> & { + error: APIError +} + +export const StepStartPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-start"), + snapshot: Schema.optional(Schema.String), +}).annotate({ identifier: "StepStartPart" }) +export type StepStartPart = Types.DeepMutable> + +export const StepFinishPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-finish"), + reason: Schema.String, + snapshot: Schema.optional(Schema.String), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), +}).annotate({ identifier: "StepFinishPart" }) +export type StepFinishPart = Types.DeepMutable> + +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.Record(Schema.String, Schema.Any), + raw: Schema.String, +}).annotate({ identifier: "ToolStatePending" }) +export type ToolStatePending = Types.DeepMutable> + +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Any), + title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateRunning" }) +export type ToolStateRunning = Types.DeepMutable> + +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Any), + output: Schema.String, + title: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Any), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + compacted: Schema.optional(NonNegativeInt), + }), + attachments: Schema.optional(Schema.Array(FilePart)), +}).annotate({ identifier: "ToolStateCompleted" }) +export type ToolStateCompleted = Types.DeepMutable> + +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Any), + error: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateError" }) +export type ToolStateError = Types.DeepMutable> + +export const ToolState = Schema.Union([ + ToolStatePending, + ToolStateRunning, + ToolStateCompleted, + ToolStateError, +]).annotate({ + discriminator: "status", + identifier: "ToolState", +}) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export const ToolPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("tool"), + callID: Schema.String, + tool: Schema.String, + state: ToolState, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "ToolPart" }) +export type ToolPart = Omit>, "state"> & { + state: ToolState +} + +const messageBase = { + id: MessageID, + sessionID: partBase.sessionID, +} + +export const User = Schema.Struct({ + ...messageBase, + role: Schema.Literal("user"), + time: Schema.Struct({ + created: Timestamp, + }), + format: Schema.optional(Format), + summary: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + diffs: Schema.Array(FileDiff.Info), + }), + ), + agent: Schema.String, + model: Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + variant: Schema.optional(Schema.String), + }), + system: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), +}).annotate({ identifier: "UserMessage" }) +export type User = Types.DeepMutable> + +export const Part = Schema.Union([ + TextPart, + SubtaskPart, + ReasoningPart, + FilePart, + ToolPart, + StepStartPart, + StepFinishPart, + SnapshotPart, + PatchPart, + AgentPart, + RetryPart, + CompactionPart, +]).annotate({ discriminator: "type", identifier: "Part" }) +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +const AssistantErrorSchema = Schema.Union([ + AuthError.EffectSchema, + namedError("UnknownError", { message: Schema.String, ref: Schema.optional(Schema.String) }).EffectSchema, + OutputLengthError.EffectSchema, + AbortedError.EffectSchema, + StructuredOutputError.EffectSchema, + ContextOverflowError.EffectSchema, + ContentFilterError.EffectSchema, + APIError.EffectSchema, +]).annotate({ discriminator: "name" }) +type AssistantError = Schema.Schema.Type + +export const TextPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPartInput" }) +export type TextPartInput = Types.DeepMutable> + +export const FilePartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePartInput" }) +export type FilePartInput = Types.DeepMutable> + +export const AgentPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPartInput" }) +export type AgentPartInput = Types.DeepMutable> + +export const SubtaskPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPartInput" }) +export type SubtaskPartInput = Types.DeepMutable> + +export const Assistant = Schema.Struct({ + ...messageBase, + role: Schema.Literal("assistant"), + time: Schema.Struct({ + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), + }), + error: Schema.optional(AssistantErrorSchema), + parentID: MessageID, + modelID: Model.ID, + providerID: Provider.ID, + mode: Schema.String, + agent: Schema.String, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, + }), + summary: Schema.optional(Schema.Boolean), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + structured: Schema.optional(Schema.Any), + variant: Schema.optional(Schema.String), + finish: Schema.optional(Schema.String), +}).annotate({ identifier: "AssistantMessage" }) +export type Assistant = Omit>, "error"> & { + error?: AssistantError +} + +export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) +export type Info = User | Assistant + +export const WithParts = Schema.Struct({ + info: Info, + parts: Schema.Array(Part), +}) +export type WithParts = { + info: Info + parts: Part[] +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const + +const SessionSummary = Schema.Struct({ + additions: Schema.Finite, + deletions: Schema.Finite, + files: Schema.Finite, + diffs: optional(Schema.Array(FileDiff.Info)), +}) + +const SessionTokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +const SessionShare = Schema.Struct({ + url: Schema.String, +}) + +const SessionRevert = Schema.Struct({ + messageID: MessageID, + partID: optional(PartID), + snapshot: optional(Schema.String), + diff: optional(Schema.String), +}) + +const SessionModel = Schema.Struct({ + id: Model.ID, + providerID: Provider.ID, + variant: optional(Schema.String), +}) + +export const SessionInfo = Schema.Struct({ + id: SessionID, + slug: Schema.String, + projectID: Project.ID, + workspaceID: optional(WorkspaceID), + directory: Schema.String, + path: optional(Schema.String), + parentID: optional(SessionID), + summary: optional(SessionSummary), + cost: optional(Schema.Finite), + tokens: optional(SessionTokens), + share: optional(SessionShare), + title: Schema.String, + agent: optional(Schema.String), + model: optional(SessionModel), + version: Schema.String, + metadata: optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optional(NonNegativeInt), + archived: optional(Schema.Finite), + }), + permission: optional(PermissionV1.Ruleset), + revert: optional(SessionRevert), +}).annotate({ identifier: "Session" }) +export type SessionInfo = typeof SessionInfo.Type + +const events = { + Created: define({ + type: "session.created", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Updated: define({ + type: "session.updated", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Deleted: define({ + type: "session.deleted", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + MessageUpdated: define({ + type: "message.updated", + ...options, + schema: { + sessionID: SessionID, + info: Info, + }, + }), + MessageRemoved: define({ + type: "message.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + }, + }), + PartUpdated: define({ + type: "message.part.updated", + ...options, + schema: { + sessionID: SessionID, + part: Part, + time: Schema.Finite, + }, + }), + PartRemoved: define({ + type: "message.part.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + }, + }), +} + +export const PartDelta = define({ + type: "message.part.delta", + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + field: Schema.String, + delta: Schema.String, + }, +}) + +export const Diff = define({ + type: "session.diff", + schema: { + sessionID: SessionID, + diff: Schema.Array(FileDiff.Info), + }, +}) + +export const Error = define({ + type: "session.error", + schema: { + sessionID: Schema.optional(SessionID), + error: Assistant.fields.error, + }, +}) + +export const Event = { + ...events, + PartDelta, + Diff, + Error, + Definitions: inventory( + events.Created, + events.Updated, + events.Deleted, + events.MessageUpdated, + events.MessageRemoved, + events.PartUpdated, + events.PartRemoved, + PartDelta, + Diff, + Error, + ), +} diff --git a/packages/schema/src/vcs-event.ts b/packages/schema/src/vcs-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c0d720dc4bea65255037b339a143c5bc050b06f --- /dev/null +++ b/packages/schema/src/vcs-event.ts @@ -0,0 +1,14 @@ +export * as VcsEvent from "./vcs-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" + +export const BranchUpdated = Event.define({ + type: "vcs.branch.updated", + schema: { + branch: optional(Schema.String), + }, +}) + +export const Definitions = Event.inventory(BranchUpdated) diff --git a/packages/schema/src/workspace-event.ts b/packages/schema/src/workspace-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..82e15e077190630ba023e6a16907ccc4555e2e6c --- /dev/null +++ b/packages/schema/src/workspace-event.ts @@ -0,0 +1,32 @@ +export * as WorkspaceEvent from "./workspace-event" + +import { Schema } from "effect" +import { Event } from "./event" +import { WorkspaceID } from "./workspace-id" + +export const ConnectionStatus = Schema.Struct({ + workspaceID: WorkspaceID, + status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), +}).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" }) +export interface ConnectionStatus extends Schema.Schema.Type {} + +export const Ready = Event.define({ + type: "workspace.ready", + schema: { + name: Schema.String, + }, +}) + +export const Failed = Event.define({ + type: "workspace.failed", + schema: { + message: Schema.String, + }, +}) + +export const Status = Event.define({ + type: "workspace.status", + schema: ConnectionStatus.fields, +}) + +export const Definitions = Event.inventory(Ready, Failed, Status) diff --git a/packages/schema/src/workspace-id.ts b/packages/schema/src/workspace-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..e43e9673f6395fae50aa7f8fc1e727d2af8b7fb4 --- /dev/null +++ b/packages/schema/src/workspace-id.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect" +import { ascending } from "./identifier" +import { statics } from "./schema" + +export const WorkspaceID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( + Schema.brand("WorkspaceV2.ID"), + statics((schema) => { + const create = () => schema.make("wrk_" + ascending()) + return { + ascending: (id?: string) => { + if (!id) return create() + if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`) + return schema.make(id) + }, + create, + } + }), +) +export type WorkspaceID = typeof WorkspaceID.Type diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce35bf3b245234f33d9322ec0d9b8fd4acfd803e --- /dev/null +++ b/packages/schema/src/workspace.ts @@ -0,0 +1,9 @@ +export * as Workspace from "./workspace" + +import { WorkspaceEvent } from "./workspace-event" +import { WorkspaceID } from "./workspace-id" + +export const ID = WorkspaceID +export type ID = WorkspaceID + +export const Event = WorkspaceEvent diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..c42ea5821e1325a08fba6d00c62f9651f99adb58 --- /dev/null +++ b/packages/schema/src/worktree-event.ts @@ -0,0 +1,22 @@ +export * as WorktreeEvent from "./worktree-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" + +export const Ready = Event.define({ + type: "worktree.ready", + schema: { + name: Schema.String, + branch: optional(Schema.String), + }, +}) + +export const Failed = Event.define({ + type: "worktree.failed", + schema: { + message: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Ready, Failed) diff --git a/packages/schema/test/compatibility.test.ts b/packages/schema/test/compatibility.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa2cf265c429fca3a47075bc394b80602c3fcf54 --- /dev/null +++ b/packages/schema/test/compatibility.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test" +import { FileSystem } from "../src/filesystem" + +describe("schema compatibility", () => { + test("moved class schemas remain constructible", () => { + const input = new FileSystem.FindInput({ query: "src" }) + expect(input).toBeInstanceOf(FileSystem.FindInput) + expect(input.query).toBe("src") + }) +}) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf83dbb288965c74c176a53152b9a780fcf2b549 --- /dev/null +++ b/packages/schema/test/contract-hygiene.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Agent } from "../src/agent" +import { FileSystem } from "../src/filesystem" +import { Model } from "../src/model" +import { Project } from "../src/project" +import { Pty } from "../src/pty" +import { Question } from "../src/question" +import { Session } from "../src/session" +import { SessionEvent } from "../src/session-event" +import { SessionTodo } from "../src/session-todo" +import { optional } from "../src/schema" + +describe("contract hygiene", () => { + test("optional properties preserve transformations and omit undefined while encoding", () => { + const Value = Schema.Struct({ value: optional(Schema.FiniteFromString) }) + expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) + expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" }) + expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({}) + }) + + test("todo status and priority preserve arbitrary strings", () => { + const decode = Schema.decodeUnknownSync(SessionTodo.Info) + expect(decode({ content: "ship", status: "waiting", priority: "urgent" })).toEqual({ + content: "ship", + status: "waiting", + priority: "urgent", + }) + }) + + test("current ID constructors expose create", () => { + expect(Question.ID.create()).toStartWith("que_") + expect(Pty.ID.create()).toStartWith("pty_") + }) + + test("reusable public identifiers are stable and unique", () => { + const identifiers = [ + Agent.Color, + FileSystem.Submatch, + Model.Ref, + Model.Capabilities, + Model.Cost, + Model.Api, + Project.Icon, + Project.Commands, + Project.Time, + Project.Info, + Pty.Info, + Session.ListAnchor, + ].map((schema) => schema.ast.annotations?.identifier) + + expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) + expect(new Set(identifiers).size).toBe(identifiers.length) + }) + + test("current source avoids Any and mutable contract wrappers", async () => { + const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( + (file) => !file.endsWith("-v1.ts"), + ) + const source = await Promise.all( + files.map((file) => Bun.file(new URL(`../src/${file}`, import.meta.url)).text()), + ).then((values) => values.join("\n")) + + expect(source).not.toContain("Schema.Any") + expect(source).not.toContain("Schema.mutable") + }) +}) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5694afdd30df3f9be4f0a8175c27af1b5dc17888 --- /dev/null +++ b/packages/schema/test/event-manifest.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src" +import { EventManifest } from "../src/event-manifest" +import { IdeEvent } from "../src/ide-event" +import { SessionEvent } from "../src/session-event" +import { SessionTodo } from "../src/session-todo" +import { SessionV1 } from "../src/session-v1" +import { WorkspaceEvent } from "../src/workspace-event" + +describe("public event manifest", () => { + test("owns the complete public event surface", () => { + expect(EventManifest.ServerDefinitions.length).toBe(55) + expect(EventManifest.Definitions.length).toBe(85) + expect(SessionV1.Event.Definitions).toEqual([ + SessionV1.Event.Created, + SessionV1.Event.Updated, + SessionV1.Event.Deleted, + SessionV1.Event.MessageUpdated, + SessionV1.Event.MessageRemoved, + SessionV1.Event.PartUpdated, + SessionV1.Event.PartRemoved, + SessionV1.Event.PartDelta, + SessionV1.Event.Diff, + SessionV1.Event.Error, + ]) + expect(EventManifest.Latest.size).toBe(85) + expect(EventManifest.Durable.size).toBe(32) + }) + + test("uses canonical definitions for current public events", () => { + expect(Session.Event).toBe(SessionEvent) + expect(Session.Event.Definitions).toBe(SessionEvent.Definitions) + expect(Workspace.Event).toBe(WorkspaceEvent) + expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) + expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) + expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) + expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) + expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited]) + expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated]) + expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied]) + expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) + expect(EventManifest.Latest.has("ide.installed")).toBe(false) + expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) + expect(EventManifest.Definitions.slice(40, 43)).toEqual([ + SessionV1.Event.PartDelta, + SessionV1.Event.Diff, + SessionV1.Event.Error, + ]) + expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) + expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + }) +}) diff --git a/packages/schema/test/event.test.ts b/packages/schema/test/event.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..380faa5a4abdb9d9ef02282b0c35d5cf66f22158 --- /dev/null +++ b/packages/schema/test/event.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Event } from "../src/event" + +describe("public event schemas", () => { + test("definition is pure", () => { + const definitions = Event.inventory() + Event.define({ type: "test.pure", schema: { value: Schema.String } }) + expect(definitions).toEqual([]) + }) + + test("latest selection is independent of declaration order", () => { + const historical = Event.define({ + type: "test.versioned", + durable: { aggregate: "id", version: 1 }, + schema: { id: Schema.String }, + }) + const current = Event.define({ + type: "test.versioned", + durable: { aggregate: "id", version: 2 }, + schema: { id: Schema.String, value: Schema.String }, + }) + + expect(Event.latest([historical, current]).get(current.type)).toBe(current) + expect(Event.latest([current, historical]).get(current.type)).toBe(current) + }) + + test("durable definitions are indexed by type and version", () => { + const definition = Event.define({ + type: "test.durable", + durable: { aggregate: "id", version: 1 }, + schema: { id: Schema.String }, + }) + + expect(Event.durable([definition]).get("test.durable.1")).toBe(definition) + }) +}) diff --git a/packages/schema/test/legacy-event.test.ts b/packages/schema/test/legacy-event.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e43c5681f2017c334b599b7cc845fab7ba19bcc9 --- /dev/null +++ b/packages/schema/test/legacy-event.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { LegacyEvent } from "../src/legacy-event" +import { PermissionV1 } from "../src/permission-v1" +import { QuestionV1 } from "../src/question-v1" +import { Project } from "../src/project" +import { SessionV1 } from "../src/session-v1" + +describe("legacy public event schemas", () => { + test("owns all SessionV1 definitions", () => { + expect(SessionV1.Event.Definitions.map((event) => event.type)).toEqual([ + "session.created", + "session.updated", + "session.deleted", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.removed", + "message.part.delta", + "session.diff", + "session.error", + ]) + const durable = SessionV1.Event.Definitions.filter((event) => event.durable !== undefined) + expect(durable).toHaveLength(7) + expect(durable.every((event) => event.durable?.aggregate === "sessionID")).toBe(true) + expect(durable.every((event) => event.durable?.version === 1)).toBe(true) + }) + + test("owns the legacy transient public definitions", () => { + expect([ + SessionV1.PartDelta.type, + SessionV1.Diff.type, + SessionV1.Error.type, + PermissionV1.Event.Asked.type, + PermissionV1.Event.Replied.type, + QuestionV1.Event.Asked.type, + QuestionV1.Event.Replied.type, + QuestionV1.Event.Rejected.type, + Project.Event.Updated.type, + LegacyEvent.CommandExecuted.type, + ]).toEqual([ + "message.part.delta", + "session.diff", + "session.error", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", + "project.updated", + "command.executed", + ]) + }) +}) diff --git a/packages/schema/test/v1-isolation.test.ts b/packages/schema/test/v1-isolation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2710d85567162b8fdcf91fecbaf7e687217e48f6 --- /dev/null +++ b/packages/schema/test/v1-isolation.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { LegacyEvent } from "../src/legacy-event" +import { PermissionV1 } from "../src/permission-v1" +import { QuestionV1 } from "../src/question-v1" +import { SessionV1 } from "../src/session-v1" +import { LegacyEvent as IsolatedLegacyEvent } from "../src/v1/legacy-event" +import { PermissionV1 as IsolatedPermissionV1 } from "../src/v1/permission" +import { QuestionV1 as IsolatedQuestionV1 } from "../src/v1/question" +import { SessionV1 as IsolatedSessionV1 } from "../src/v1/session" + +test("compatibility entrypoints preserve isolated V1 schema identity", () => { + expect(LegacyEvent).toBe(IsolatedLegacyEvent) + expect(PermissionV1).toBe(IsolatedPermissionV1) + expect(QuestionV1).toBe(IsolatedQuestionV1) + expect(SessionV1).toBe(IsolatedSessionV1) +}) + +test("current source does not import the V1 subtree directly", async () => { + const allowed = new Set(["legacy-event.ts", "permission-v1.ts", "question-v1.ts", "session-v1.ts"]) + const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( + (file) => !allowed.has(file), + ) + const directImports = await Promise.all( + files.map(async (file) => ({ file, source: await Bun.file(new URL(`../src/${file}`, import.meta.url)).text() })), + ).then((values) => values.filter((value) => value.source.includes('from "./v1/'))) + + expect(directImports).toEqual([]) +}) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d148ce0d2b708763d201e64152f9d0cb8cb0a28c --- /dev/null +++ b/packages/script/src/index.ts @@ -0,0 +1,77 @@ +import { $ } from "bun" +import semver from "semver" +import path from "path" + +const rootPkgPath = path.resolve(import.meta.dir, "../../../package.json") +const rootPkg = await Bun.file(rootPkgPath).json() +const expectedBunVersion = rootPkg.packageManager?.split("@")[1] + +if (!expectedBunVersion) { + throw new Error("packageManager field not found in root package.json") +} + +// relax version requirement +const expectedBunVersionRange = `^${expectedBunVersion}` + +if (!semver.satisfies(process.versions.bun, expectedBunVersionRange)) { + throw new Error(`This script requires bun@${expectedBunVersionRange}, but you are using bun@${process.versions.bun}`) +} + +const env = { + OPENCODE_CHANNEL: process.env["OPENCODE_CHANNEL"], + OPENCODE_BUMP: process.env["OPENCODE_BUMP"], + OPENCODE_VERSION: process.env["OPENCODE_VERSION"], + OPENCODE_RELEASE: process.env["OPENCODE_RELEASE"], +} +const CHANNEL = await (async () => { + if (env.OPENCODE_CHANNEL) return env.OPENCODE_CHANNEL + if (env.OPENCODE_BUMP) return "latest" + if (env.OPENCODE_VERSION && !env.OPENCODE_VERSION.startsWith("0.0.0-")) return "latest" + return await $`git branch --show-current`.text().then((x) => x.trim()) +})() +const IS_PREVIEW = CHANNEL !== "latest" + +const VERSION = await (async () => { + if (env.OPENCODE_VERSION) return env.OPENCODE_VERSION + if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}` + const version = await fetch("https://registry.npmjs.org/opencode-ai/latest") + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.version) + const [major, minor, patch] = version.split(".").map((x: string) => Number(x) || 0) + const t = env.OPENCODE_BUMP?.toLowerCase() + if (t === "major") return `${major + 1}.0.0` + if (t === "minor") return `${major}.${minor + 1}.0` + return `${major}.${minor}.${patch + 1}` +})() + +const bot = ["actions-user", "opencode", "opencode-agent[bot]"] +const teamPath = path.resolve(import.meta.dir, "../../../.github/TEAM_MEMBERS") +const team = [ + ...(await Bun.file(teamPath) + .text() + .then((x) => x.split(/\r?\n/).map((x) => x.trim())) + .then((x) => x.filter((x) => x && !x.startsWith("#")))), + ...bot, +] + +export const Script = { + get channel() { + return CHANNEL + }, + get version() { + return VERSION + }, + get preview() { + return IS_PREVIEW + }, + get release(): boolean { + return !!env.OPENCODE_RELEASE + }, + get team() { + return team + }, +} +console.log(`opencode script`, JSON.stringify(Script, null, 2)) diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc23219fc40e1d8dceb20fc0bc6fad1a4b44a637 --- /dev/null +++ b/packages/sdk-next/src/index.ts @@ -0,0 +1,17 @@ +export * as OpenCode from "./opencode" +export * as Tool from "./tool" + +export { ClientError } from "@opencode-ai/client/effect" +export { + AbsolutePath, + Agent, + Location, + Model, + Prompt, + Provider, + RelativePath, + Session, + SessionInput, + SessionMessage, +} from "@opencode-ai/client/effect" +export type { OpenCodeEvent } from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts new file mode 100644 index 0000000000000000000000000000000000000000..096b46d4b719d4e561f44191dc8e04eb114782e3 --- /dev/null +++ b/packages/sdk-next/src/opencode.ts @@ -0,0 +1,49 @@ +import { OpenCode } from "@opencode-ai/client/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { createEmbeddedRoutes } from "@opencode-ai/server/routes" +import { Context, Effect, Layer, Scope } from "effect" +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" + +export const create = Effect.fn("OpenCode.create")(function* () { + const scope = yield* Scope.Scope + const memoMap = yield* Layer.makeMemoMap + const context = yield* Layer.buildWithMemoMap( + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, PermissionSaved.node])), + memoMap, + scope, + ) + const tools = Context.get(context, ApplicationTools.Service) + const permissions = Context.get(context, PermissionSaved.Service) + const web = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + createEmbeddedRoutes().pipe( + HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true, memoMap }, + ), + ), + (web) => Effect.promise(web.dispose), + ) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + preconnect: () => undefined, + }) satisfies typeof globalThis.fetch + const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetch), + ) + return { + ...client, + tools: { register: tools.register }, + } +}) + +export type Interface = Effect.Success> + +export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} + +export const layer = Layer.effect(Service, create()) diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b572a6260c579df9ac25f8029ef014281296377 --- /dev/null +++ b/packages/sdk-next/src/tool.ts @@ -0,0 +1,2 @@ +export { Failure, RegistrationError, make } from "@opencode-ai/core/tool/tool" +export type { AnyTool, Content, Context, Definition } from "@opencode-ai/core/tool/tool" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c1b8b238a3c7179933ff09d2f4fd62ec236d332 --- /dev/null +++ b/packages/sdk-next/test/embedded.test.ts @@ -0,0 +1,212 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" +import type { OpenCodeEvent } from "../src" + +test("embedded client uses the real router and handlers", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + yield* opencode.tools.register({ + embedded_tool: Tool.make({ + description: "Embedded test tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) + + const created = yield* opencode.sessions.create({ + id: sessionID, + agent: Agent.ID.make("build"), + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.switchModel({ sessionID, model }) + const selected = yield* opencode.sessions.get({ sessionID }) + const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) + const active = yield* opencode.sessions.active() + const admitted = yield* opencode.sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Do not run" }), + resume: false, + }) + const context = yield* opencode.sessions.context({ sessionID }) + const wake = yield* opencode.sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Promote this input" }), + }) + const prompted = yield* opencode.sessions.events({ sessionID }).pipe( + Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), + Stream.runHead, + Effect.timeout("10 seconds"), + Effect.map(Option.getOrThrow), + ) + const wakeContext = yield* opencode.sessions.context({ sessionID }) + const event = yield* opencode.sessions + .events({ sessionID }) + .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined)) + const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe( + Option.getOrThrow, + ) + const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id }) + yield* opencode.sessions.interrupt({ sessionID }) + const other = yield* opencode.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`) + const missing = yield* Effect.all( + [ + opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), + opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), + opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), + ], + { concurrency: "unbounded" }, + ) + const missingMessage = yield* Effect.flip( + opencode.sessions.message({ + sessionID: other.id, + messageID: modelMessage.id, + }), + ) + + expect(created.id).toBe(sessionID) + expect(selected.model?.id).toBe(model.id) + expect(selected.model?.providerID).toBe(model.providerID) + expect(page.data.some((session) => session.id === sessionID)).toBe(true) + expect(active).toEqual({}) + expect(admitted.sessionID).toBe(sessionID) + expect(prompted.type).toBe("session.next.prompted") + expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) + expect(context.some((message) => message.type === "model-switched")).toBe(true) + expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) + expect(message).toEqual(modelMessage) + expect(missing.map((error) => error._tag)).toEqual([ + "SessionNotFoundError", + "SessionNotFoundError", + "SessionNotFoundError", + ]) + expect(missingMessage._tag).toBe("MessageNotFoundError") + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) + +test("Location-owned runner events reach the ready global client", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + const connected = yield* Latch.make(false) + const prompted = yield* Deferred.make() + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => + event.type === "server.connected" + ? connected.open + : event.type === "session.next.prompted" && event.data.sessionID === sessionID + ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) }) + + const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) + expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) })) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}, 10_000) + +test("independent embedded hosts do not share live notifications", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const first = yield* OpenCode.create() + const second = yield* OpenCode.create() + const firstReady = yield* Latch.make(false) + const secondReady = yield* Latch.make(false) + const firstEvent = yield* Latch.make(false) + const secondEvent = yield* Latch.make(false) + const observe = (ready: Latch.Latch, event: Latch.Latch) => + Stream.runForEach((notification: OpenCodeEvent) => + notification.type === "server.connected" + ? ready.open + : notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID + ? event.open + : Effect.void, + ) + + yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) + yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) + yield* Effect.all([firstReady.await, secondReady.await], { discard: true }) + yield* first.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") }) + + yield* firstEvent.await.pipe(Effect.timeout("2 seconds")) + expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}, 10_000) + +test("embedded client is available as a Layer service", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const created = await Effect.runPromise( + Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + }).pipe(Effect.provide(OpenCode.layer), Effect.scoped), + ) + + expect(created.id).toBe(sessionID) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/sdk-next/test/import-boundaries.test.ts b/packages/sdk-next/test/import-boundaries.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6a7d178e20fb100e1767036f3ded94f6cf5204d --- /dev/null +++ b/packages/sdk-next/test/import-boundaries.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const client = resolve(import.meta.dir, "../../client") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +test("bundles the client and in-memory host", async () => { + const inputs = await bundleInputs() + + expect(within(inputs, client).length).toBeGreaterThan(0) + expect(within(inputs, core).length).toBeGreaterThan(0) + expect(within(inputs, server).length).toBeGreaterThan(0) +}) + +async function bundleInputs() { + const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) + const entrypoint = join(temporary, "index.ts") + const metafile = join(temporary, "meta.json") + try { + await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"') + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + "--target=bun", + "--format=esm", + "--packages=bundle", + `--metafile=${metafile}`, + `--outdir=${join(temporary, "out")}`, + ], + { cwd: directory, stdout: "pipe", stderr: "pipe" }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (exitCode !== 0) throw new Error(stdout + stderr) + const metadata = await Bun.file(metafile).json() + return Object.keys(metadata.inputs).map((input) => resolve(directory, input)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +function within(inputs: ReadonlyArray, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/sdk/js/example/example.ts b/packages/sdk/js/example/example.ts new file mode 100644 index 0000000000000000000000000000000000000000..42838a82a7e670f7a2a3809240af8cd49d6fc8af --- /dev/null +++ b/packages/sdk/js/example/example.ts @@ -0,0 +1,56 @@ +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[] = [] +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) + }), +) diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json new file mode 100644 index 0000000000000000000000000000000000000000..a471612ecaf269a8afdeb7c1acb2f713ba6209a5 --- /dev/null +++ b/packages/sdk/js/package.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/sdk", + "version": "1.18.31", + "type": "module", + "license": "MIT", + "scripts": { + "test": "bun test", + "typecheck": "tsgo --noEmit", + "build": "bun ./script/build.ts" + }, + "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:" + } +} diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts new file mode 100644 index 0000000000000000000000000000000000000000..79e0879c9e14637fc7e556a271449353501cf02c --- /dev/null +++ b/packages/sdk/js/script/build.ts @@ -0,0 +1,119 @@ +#!/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") + +await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) + +const document = (await Bun.file("./openapi.json").json()) as { + components?: { schemas?: Record } + [key: string]: unknown +} +const schemas = document.components?.schemas +if (schemas) { + const reachable = new Set() + 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 (/^SessionNext\w+1$/.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 generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text() +if (/export type SessionNext\w+1 =/.test(generatedTypes)) { + throw new Error("Session history generated duplicate Session event variants") +} +const historyTypesPatched = generatedTypes.replace( + /(export type V2SessionHistoryData = \{[\s\S]*?query\?: \{\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historyTypesPatched === generatedTypes) { + throw new Error("Session history numeric query patch did not apply") +} +await Bun.write("./src/v2/gen/types.gen.ts", historyTypesPatched) + +const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text() +const historySdkPatched = generatedSdk.replace( + /(Get session history[\s\S]*?parameters: \{\s*sessionID: string[;,]\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historySdkPatched === generatedSdk) { + throw new Error("Session history numeric SDK patch did not apply") +} +await Bun.write("./src/v2/gen/sdk.gen.ts", historySdkPatched) + +// 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>", + "=> Promise>", +) +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` diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts new file mode 100644 index 0000000000000000000000000000000000000000..29426a41b7ddb3c196f8d5d6041944749a293805 --- /dev/null +++ b/packages/sdk/js/script/publish.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env bun + +import { Script } from "@opencode-ai/script" +import { $ } from "bun" +import { fileURLToPath } from "url" + +const dir = fileURLToPath(new URL("..", import.meta.url)) +process.chdir(dir) + +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + +const originalText = await Bun.file("package.json").text() +const pkg = JSON.parse(originalText) as { + name: string + version: string + exports: Record +} +function transformExports(exports: Record) { + return Object.fromEntries( + Object.entries(exports).map(([key, value]) => { + if (typeof value === "string") { + const file = value.replace("./src/", "./dist/").replace(".ts", "") + return [key, { import: file + ".js", types: file + ".d.ts" }] + } + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return [key, transformExports(value)] + } + return [key, value] + }), + ) +} +if (await published(pkg.name, pkg.version)) { + console.log(`already published ${pkg.name}@${pkg.version}`) +} else { + pkg.exports = transformExports(pkg.exports) + await Bun.write("package.json", JSON.stringify(pkg, null, 2)) + try { + await $`bun pm pack` + await $`npm publish *.tgz --tag ${Script.channel} --access public` + } finally { + await Bun.write("package.json", originalText) + } +} diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..5cf071e7b7abb0ea9680c4b843d9dd2ef6e3d51c --- /dev/null +++ b/packages/sdk/js/src/client.ts @@ -0,0 +1,57 @@ +export * from "./gen/types.gen.js" + +import { createClient } from "./gen/client/client.gen.js" +import { type Config } from "./gen/client/types.gen.js" +import { OpencodeClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "./error-interceptor.js" +export { type Config as OpencodeClientConfig, OpencodeClient } + +function pick(value: string | null, fallback?: string) { + if (!value) return + if (!fallback) return value + if (value === fallback) return fallback + if (value === encodeURIComponent(fallback)) return fallback + return value +} + +function rewrite(request: Request, directory?: string) { + if (request.method !== "GET" && request.method !== "HEAD") return request + + const value = pick(request.headers.get("x-opencode-directory"), directory) + if (!value) return request + + const url = new URL(request.url) + if (!url.searchParams.has("directory")) { + url.searchParams.set("directory", value) + } + + const next = new Request(url, request) + next.headers.delete("x-opencode-directory") + return next +} + +export function createOpencodeClient(config?: Config & { directory?: string }) { + if (!config?.fetch) { + const customFetch: any = (req: any) => { + // @ts-ignore + req.timeout = false + return fetch(req) + } + config = { + ...config, + fetch: customFetch, + } + } + + if (config?.directory) { + config.headers = { + ...config.headers, + "x-opencode-directory": encodeURIComponent(config.directory), + } + } + + const client = createClient(config) + client.interceptors.request.use((request) => rewrite(request, config?.directory)) + client.interceptors.error.use(wrapClientError) + return new OpencodeClient({ client }) +} diff --git a/packages/sdk/js/src/error-interceptor.ts b/packages/sdk/js/src/error-interceptor.ts new file mode 100644 index 0000000000000000000000000000000000000000..26407ecfc9038121fe9c14342052b5924bebe390 --- /dev/null +++ b/packages/sdk/js/src/error-interceptor.ts @@ -0,0 +1,51 @@ +/** + * Wrap whatever the generated client decoded from a non-2xx error body + * into a real `Error` so downstream formatters (TUI, plugins) get a + * useful `.message` instead of `[object Object]` or blank. The original + * parsed body and status live under `.cause` for callers that need + * structured fields. + * + * Only fires when the caller used `{ throwOnError: true }`. Callers that + * read `result.error` directly (the result-tuple path) get the parsed + * body unchanged so existing field-level reads (`.error.name`, + * `JSON.stringify(error)`, etc.) are byte-for-byte identical to before. + */ +export function wrapClientError( + error: unknown, + response: Response | undefined, + request: Request | undefined, + opts: { throwOnError?: boolean } | undefined, +): unknown { + if (!opts?.throwOnError) return error + if (error instanceof Error) return error + + // NamedError-shaped responses (the common case for opencode 4xx) come + // through as POJOs — extract a useful message first, then wrap. + if (typeof error === "object" && error !== null && Object.keys(error).length > 0) { + const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown } + const message = + (typeof obj.data?.message === "string" && obj.data.message) || + (typeof obj.message === "string" && obj.message) || + (typeof obj.name === "string" && obj.name) || + describe(request, response) + return new Error(message, { cause: { body: error, status: response?.status } }) + } + + if (typeof error === "string" && error.length > 0) { + return new Error(error, { cause: { body: error, status: response?.status } }) + } + + // Empty body / network failure / undefined / null / empty object. + const reason = response ? "(empty response body)" : "network error (no response)" + return new Error(`opencode server ${describe(request, response)}: ${reason}`, { + cause: { body: error, status: response?.status }, + }) +} + +function describe(request: Request | undefined, response: Response | undefined) { + const method = request?.method ?? "?" + const url = request?.url ?? "?" + const status = response?.status + const statusText = response?.statusText + return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}` +} diff --git a/packages/sdk/js/src/gen/client.gen.ts b/packages/sdk/js/src/gen/client.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7cdb292c68021fb8944e4dba8574d293794be6f --- /dev/null +++ b/packages/sdk/js/src/gen/client.gen.ts @@ -0,0 +1,22 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ClientOptions } from "./types.gen.js" +import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from "./client/index.js" + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T> + +export const client = createClient( + createConfig({ + baseUrl: "http://localhost:4096", + }), +) diff --git a/packages/sdk/js/src/gen/client/client.gen.ts b/packages/sdk/js/src/gen/client/client.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..34a8d0beceb9e1b6664a53717762d031c3e6c52b --- /dev/null +++ b/packages/sdk/js/src/gen/client/client.gen.ts @@ -0,0 +1,212 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from "../core/serverSentEvents.gen.js" +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js" +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from "./utils.gen.js" + +type ReqInit = Omit & { + body?: any + headers: ReturnType +} + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config) + + const getConfig = (): Config => ({ ..._config }) + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config) + return getConfig() + } + + const interceptors = createInterceptors() + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + } + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }) + } + + if (opts.requestValidator) { + await opts.requestValidator(opts) + } + + if (opts.body && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.serializedBody === undefined || opts.serializedBody === "") { + opts.headers.delete("Content-Type") + } + + const url = buildUrl(opts) + + return { opts, url } + } + + const request: Client["request"] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options) + const requestInit: ReqInit = { + redirect: "follow", + ...opts, + body: opts.serializedBody, + } + + let request = new Request(url, requestInit) + + for (const fn of interceptors.request._fns) { + if (fn) { + request = await fn(request, opts) + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch! + let response = await _fetch(request) + + for (const fn of interceptors.response._fns) { + if (fn) { + response = await fn(response, request, opts) + } + } + + const result = { + request, + response, + } + + if (response.ok) { + if (response.status === 204 || response.headers.get("Content-Length") === "0") { + return opts.responseStyle === "data" + ? {} + : { + data: {}, + ...result, + } + } + + const parseAs = + (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json" + + let data: any + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "formData": + case "json": + case "text": + data = await response[parseAs]() + break + case "stream": + return opts.responseStyle === "data" + ? response.body + : { + data: response.body, + ...result, + } + } + + if (parseAs === "json") { + if (opts.responseValidator) { + await opts.responseValidator(data) + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data) + } + } + + return opts.responseStyle === "data" + ? data + : { + data, + ...result, + } + } + + const textError = await response.text() + let jsonError: unknown + + try { + jsonError = JSON.parse(textError) + } catch { + // noop + } + + const error = jsonError ?? textError + let finalError = error + + for (const fn of interceptors.error._fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string + } + } + + finalError = finalError || ({} as string) + + if (opts.throwOnError) { + throw finalError + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === "data" + ? undefined + : { + error: finalError, + ...result, + } + } + + const makeMethod = (method: Required["method"]) => { + const fn = (options: RequestOptions) => request({ ...options, method }) + fn.sse = async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options) + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + url, + }) + } + return fn + } + + return { + buildUrl, + connect: makeMethod("CONNECT"), + delete: makeMethod("DELETE"), + get: makeMethod("GET"), + getConfig, + head: makeMethod("HEAD"), + interceptors, + options: makeMethod("OPTIONS"), + patch: makeMethod("PATCH"), + post: makeMethod("POST"), + put: makeMethod("PUT"), + request, + setConfig, + trace: makeMethod("TRACE"), + } as Client +} diff --git a/packages/sdk/js/src/gen/client/index.ts b/packages/sdk/js/src/gen/client/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..06f21e3d802b9cbb37b5db20677245291d5a7791 --- /dev/null +++ b/packages/sdk/js/src/gen/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from "../core/auth.gen.js" +export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from "../core/bodySerializer.gen.js" +export { buildClientParams } from "../core/params.gen.js" +export { createClient } from "./client.gen.js" +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + OptionsLegacyParser, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from "./types.gen.js" +export { createConfig, mergeHeaders } from "./utils.gen.js" diff --git a/packages/sdk/js/src/gen/client/types.gen.ts b/packages/sdk/js/src/gen/client/types.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..db8e544cfde3d32c71fb0cdaa52ea00036dd6ba7 --- /dev/null +++ b/packages/sdk/js/src/gen/client/types.gen.ts @@ -0,0 +1,222 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from "../core/auth.gen.js" +import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen.js" +import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js" +import type { Middleware } from "./utils.gen.js" + +export type ResponseStyle = "data" | "fields" + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T["baseUrl"] + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: (request: Request) => ReturnType + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text" + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T["throwOnError"] +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle + throwOnError: ThrowOnError + }>, + Pick< + ServerSentEventsOptions, + "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay" + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown + path?: Record + query?: Record + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray + url: Url +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends "data" + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData + request: Request + response: Response + } + > + : Promise< + TResponseStyle extends "data" + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData + error: undefined + } + | { + data: undefined + error: TError extends Record ? TError[keyof TError] : TError + } + ) & { + request: Request + response: Response + } + > + +export interface ClientOptions { + baseUrl?: string + responseStyle?: ResponseStyle + throwOnError?: boolean +} + +type MethodFnBase = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => RequestResult + +type MethodFnServerSentEvents = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => Promise> + +type MethodFn = MethodFnBase & { + sse: MethodFnServerSentEvents +} + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method"> & + Pick>, "method">, +) => RequestResult + +type BuildUrlFn = < + TData extends { + body?: unknown + path?: Record + query?: Record + url: string + }, +>( + options: Pick & Options, +) => string + +export type Client = CoreClient & { + interceptors: Middleware +} + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T> + +export interface TDataShape { + body?: unknown + headers?: unknown + path?: unknown + query?: unknown + url: string +} + +type OmitKeys = Pick> + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = "fields", +> = OmitKeys, "body" | "path" | "query" | "url"> & + Omit + +export type OptionsLegacyParser< + TData = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = TData extends { body?: any } + ? TData extends { headers?: any } + ? OmitKeys, "body" | "headers" | "url"> & TData + : OmitKeys, "body" | "url"> & + TData & + Pick, "headers"> + : TData extends { headers?: any } + ? OmitKeys, "headers" | "url"> & + TData & + Pick, "body"> + : OmitKeys, "url"> & TData diff --git a/packages/sdk/js/src/gen/client/utils.gen.ts b/packages/sdk/js/src/gen/client/utils.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..209bfbe8e620bae63894c0a47ca62d15a1e60f51 --- /dev/null +++ b/packages/sdk/js/src/gen/client/utils.gen.ts @@ -0,0 +1,287 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from "../core/auth.gen.js" +import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" +import { jsonBodySerializer } from "../core/bodySerializer.gen.js" +import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen.js" +import { getUrl } from "../core/utils.gen.js" +import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js" + +export const createQuerySerializer = ({ allowReserved, array, object }: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = [] + if (queryParams && typeof queryParams === "object") { + for (const name in queryParams) { + const value = queryParams[name] + + if (value === undefined || value === null) { + continue + } + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved, + explode: true, + name, + style: "form", + value, + ...array, + }) + if (serializedArray) search.push(serializedArray) + } else if (typeof value === "object") { + const serializedObject = serializeObjectParam({ + allowReserved, + explode: true, + name, + style: "deepObject", + value: value as Record, + ...object, + }) + if (serializedObject) search.push(serializedObject) + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }) + if (serializedPrimitive) search.push(serializedPrimitive) + } + } + } + return search.join("&") + } + return querySerializer +} + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return "stream" + } + + const cleanContent = contentType.split(";")[0]?.trim() + + if (!cleanContent) { + return + } + + if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) { + return "json" + } + + if (cleanContent === "multipart/form-data") { + return "formData" + } + + if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) { + return "blob" + } + + if (cleanContent.startsWith("text/")) { + return "text" + } + + return +} + +const checkForExistence = ( + options: Pick & { + headers: Headers + }, + name?: string, +): boolean => { + if (!name) { + return false + } + if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) { + return true + } + return false +} + +export const setAuthParams = async ({ + security, + ...options +}: Pick, "security"> & + Pick & { + headers: Headers + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue + } + + const token = await getAuthToken(auth, options.auth) + + if (!token) { + continue + } + + const name = auth.name ?? "Authorization" + + switch (auth.in) { + case "query": + if (!options.query) { + options.query = {} + } + options.query[name] = token + break + case "cookie": + options.headers.append("Cookie", `${name}=${token}`) + break + case "header": + default: + options.headers.set(name, token) + break + } + } +} + +export const buildUrl: Client["buildUrl"] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === "function" + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }) + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b } + if (config.baseUrl?.endsWith("/")) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1) + } + config.headers = mergeHeaders(a.headers, b.headers) + return config +} + +export const mergeHeaders = (...headers: Array["headers"] | undefined>): Headers => { + const mergedHeaders = new Headers() + for (const header of headers) { + if (!header || typeof header !== "object") { + continue + } + + const iterator = header instanceof Headers ? header.entries() : Object.entries(header) + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key) + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string) + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string)) + } + } + } + return mergedHeaders +} + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise + +type ResInterceptor = (response: Res, request: Req, options: Options) => Res | Promise + +class Interceptors { + _fns: (Interceptor | null)[] + + constructor() { + this._fns = [] + } + + clear() { + this._fns = [] + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === "number") { + return this._fns[id] ? id : -1 + } else { + return this._fns.indexOf(id) + } + } + exists(id: number | Interceptor) { + const index = this.getInterceptorIndex(id) + return !!this._fns[index] + } + + eject(id: number | Interceptor) { + const index = this.getInterceptorIndex(id) + if (this._fns[index]) { + this._fns[index] = null + } + } + + update(id: number | Interceptor, fn: Interceptor) { + const index = this.getInterceptorIndex(id) + if (this._fns[index]) { + this._fns[index] = fn + return id + } else { + return false + } + } + + use(fn: Interceptor) { + this._fns = [...this._fns, fn] + return this._fns.length - 1 + } +} + +// `createInterceptors()` response, meant for external use as it does not +// expose internals +export interface Middleware { + error: Pick>, "eject" | "use"> + request: Pick>, "eject" | "use"> + response: Pick>, "eject" | "use"> +} + +// do not add `Middleware` as return type so we can use _fns internally +export const createInterceptors = () => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}) + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: "form", + }, + object: { + explode: true, + style: "deepObject", + }, +}) + +const defaultHeaders = { + "Content-Type": "application/json", +} + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: "auto", + querySerializer: defaultQuerySerializer, + ...override, +}) diff --git a/packages/sdk/js/src/gen/core/auth.gen.ts b/packages/sdk/js/src/gen/core/auth.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc7b230f4475a6a52271a6e6ac77437c244cea32 --- /dev/null +++ b/packages/sdk/js/src/gen/core/auth.gen.ts @@ -0,0 +1,41 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: "header" | "query" | "cookie" + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string + scheme?: "basic" | "bearer" + type: "apiKey" | "http" +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === "function" ? await callback(auth) : callback + + if (!token) { + return + } + + if (auth.scheme === "bearer") { + return `Bearer ${token}` + } + + if (auth.scheme === "basic") { + return `Basic ${btoa(token)}` + } + + return token +} diff --git a/packages/sdk/js/src/gen/core/bodySerializer.gen.ts b/packages/sdk/js/src/gen/core/bodySerializer.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..0660616052bb7ef63a1f722f4ab17850cfba27dc --- /dev/null +++ b/packages/sdk/js/src/gen/core/bodySerializer.gen.ts @@ -0,0 +1,74 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js" + +export type QuerySerializer = (query: Record) => string + +export type BodySerializer = (body: any) => any + +export interface QuerySerializerOptions { + allowReserved?: boolean + array?: SerializerOptions + object?: SerializerOptions +} + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === "string" || value instanceof Blob) { + data.append(key, value) + } else if (value instanceof Date) { + data.append(key, value.toISOString()) + } else { + data.append(key, JSON.stringify(value)) + } +} + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === "string") { + data.append(key, value) + } else { + data.append(key, JSON.stringify(value)) + } +} + +export const formDataBodySerializer = { + bodySerializer: | Array>>(body: T): FormData => { + const data = new FormData() + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)) + } else { + serializeFormDataPair(data, key, value) + } + }) + + return data + }, +} + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), +} + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>(body: T): string => { + const data = new URLSearchParams() + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) + } else { + serializeUrlSearchParamsPair(data, key, value) + } + }) + + return data.toString() + }, +} diff --git a/packages/sdk/js/src/gen/core/params.gen.ts b/packages/sdk/js/src/gen/core/params.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..68ad1a778ee999d7e360a678a99151afe67e6b74 --- /dev/null +++ b/packages/sdk/js/src/gen/core/params.gen.ts @@ -0,0 +1,144 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = "body" | "headers" | "path" | "query" + +export type Field = + | { + in: Exclude + /** + * Field name. This is the name we want the user to see and use. + */ + key: string + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string + } + | { + in: Extract + /** + * Key isn't required for bodies. + */ + key?: string + map?: string + } + +export interface Fields { + allowExtra?: Partial> + args?: ReadonlyArray +} + +export type FieldsConfig = ReadonlyArray + +const extraPrefixesMap: Record = { + $body_: "body", + $headers_: "headers", + $path_: "path", + $query_: "query", +} +const extraPrefixes = Object.entries(extraPrefixesMap) + +type KeyMap = Map< + string, + { + in: Slot + map?: string + } +> + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map() + } + + for (const config of fields) { + if ("in" in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }) + } + } else if (config.args) { + buildKeyMap(config.args, map) + } + } + + return map +} + +interface Params { + body: unknown + headers: Record + path: Record + query: Record +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === "object" && !Object.keys(value).length) { + delete params[slot as Slot] + } + } +} + +export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + } + + const map = buildKeyMap(fields) + + let config: FieldsConfig[number] | undefined + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index] + } + + if (!config) { + continue + } + + if ("in" in config) { + if (config.key) { + const field = map.get(config.key)! + const name = field.map || config.key + ;(params[field.in] as Record)[name] = arg + } else { + params.body = arg + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key) + + if (field) { + const name = field.map || key + ;(params[field.in] as Record)[name] = value + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) + + if (extra) { + const [prefix, slot] = extra + ;(params[slot] as Record)[key.slice(prefix.length)] = value + } else { + for (const [slot, allowed] of Object.entries(config.allowExtra ?? {})) { + if (allowed) { + ;(params[slot as Slot] as Record)[key] = value + break + } + } + } + } + } + } + } + + stripEmptySlots(params) + + return params +} diff --git a/packages/sdk/js/src/gen/core/pathSerializer.gen.ts b/packages/sdk/js/src/gen/core/pathSerializer.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..96be3bc5a3979b818ee3bd471a84ccd8d454ecd6 --- /dev/null +++ b/packages/sdk/js/src/gen/core/pathSerializer.gen.ts @@ -0,0 +1,167 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean + name: string +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean + style: T +} + +export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited" +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle +type MatrixStyle = "label" | "matrix" | "simple" +export type ObjectStyle = "form" | "deepObject" +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "label": + return "." + case "matrix": + return ";" + case "simple": + return "," + default: + return "&" + } +} + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "form": + return "," + case "pipeDelimited": + return "|" + case "spaceDelimited": + return "%20" + default: + return "," + } +} + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case "label": + return "." + case "matrix": + return ";" + case "simple": + return "," + default: + return "&" + } +} + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[] +}) => { + if (!explode) { + const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( + separatorArrayNoExplode(style), + ) + switch (style) { + case "label": + return `.${joinedValues}` + case "matrix": + return `;${name}=${joinedValues}` + case "simple": + return joinedValues + default: + return `${name}=${joinedValues}` + } + } + + const separator = separatorArrayExplode(style) + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string) + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }) + }) + .join(separator) + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues +} + +export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return "" + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ) + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}` +} + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date + valueOnly?: boolean +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` + } + + if (style !== "deepObject" && !explode) { + let values: string[] = [] + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)] + }) + const joinedValues = values.join(",") + switch (style) { + case "form": + return `${name}=${joinedValues}` + case "label": + return `.${joinedValues}` + case "matrix": + return `;${name}=${joinedValues}` + default: + return joinedValues + } + } + + const separator = separatorObjectExplode(style) + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator) + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues +} diff --git a/packages/sdk/js/src/gen/core/queryKeySerializer.gen.ts b/packages/sdk/js/src/gen/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..320204aef108409af21c70cc51c9d2e6a606fe63 --- /dev/null +++ b/packages/sdk/js/src/gen/core/queryKeySerializer.gen.ts @@ -0,0 +1,111 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue } + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined + } + if (typeof value === "bigint") { + return value.toString() + } + if (value instanceof Date) { + return value.toISOString() + } + return value +} + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer) + if (json === undefined) { + return undefined + } + return JSON.parse(json) as JsonValue + } catch { + return undefined + } +} + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false + } + const prototype = Object.getPrototypeOf(value as object) + return prototype === Object.prototype || prototype === null +} + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)) + const result: Record = {} + + for (const [key, value] of entries) { + const existing = result[key] + if (existing === undefined) { + result[key] = value + continue + } + + if (Array.isArray(existing)) { + ;(existing as string[]).push(value) + } else { + result[key] = [existing, value] + } + } + + return result +} + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value + } + + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined + } + + if (typeof value === "bigint") { + return value.toString() + } + + if (value instanceof Date) { + return value.toISOString() + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value) + } + + if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) { + return serializeSearchParams(value) + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value) + } + + return undefined +} diff --git a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffc4f16dc1f318fe796f1eedd68c86c4932da222 --- /dev/null +++ b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts @@ -0,0 +1,210 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from "./types.gen.js" + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise + url: string + } + +export interface StreamEvent { + data: TData + event?: string + id?: string + retry?: number +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator ? TData[keyof TData] : TData, TReturn, TNext> +} + +export const createSseClient = ({ + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000 + let attempt = 0 + const signal = options.signal ?? new AbortController().signal + + while (true) { + if (signal.aborted) break + + attempt++ + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined) + + if (lastEventId !== undefined) { + headers.set("Last-Event-ID", lastEventId) + } + + try { + const response = await fetch(url, { ...options, headers, signal }) + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`) + + if (!response.body) throw new Error("No body in SSE response") + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + + let buffer = "" + + const abortHandler = () => { + try { + void reader.cancel() + } catch { + // noop + } + } + + signal.addEventListener("abort", abortHandler) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += value + + const chunks = buffer.split("\n\n") + buffer = chunks.pop() ?? "" + + for (const chunk of chunks) { + const lines = chunk.split("\n") + const dataLines: Array = [] + let eventName: string | undefined + + for (const line of lines) { + if (line.startsWith("data:")) { + dataLines.push(line.replace(/^data:\s*/, "")) + } else if (line.startsWith("event:")) { + eventName = line.replace(/^event:\s*/, "") + } else if (line.startsWith("id:")) { + lastEventId = line.replace(/^id:\s*/, "") + } else if (line.startsWith("retry:")) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) + if (!Number.isNaN(parsed)) { + retryDelay = parsed + } + } + } + + let data: unknown + let parsedJson = false + + if (dataLines.length) { + const rawData = dataLines.join("\n") + try { + data = JSON.parse(rawData) + parsedJson = true + } catch { + data = rawData + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data) + } + + if (responseTransformer) { + data = await responseTransformer(data) + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }) + + if (dataLines.length) { + yield data as any + } + } + } + } finally { + signal.removeEventListener("abort", abortHandler) + reader.releaseLock() + } + + break // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error) + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000) + await sleep(backoff) + } + } + } + + const stream = createStream() + + return { stream } +} diff --git a/packages/sdk/js/src/gen/core/types.gen.ts b/packages/sdk/js/src/gen/core/types.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..16408b2d09ce77734ce91e6e95af2fe8e36ff65f --- /dev/null +++ b/packages/sdk/js/src/gen/core/types.gen.ts @@ -0,0 +1,91 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from "./auth.gen.js" +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js" + +export interface Client { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn + connect: MethodFn + delete: MethodFn + get: MethodFn + getConfig: () => Config + head: MethodFn + options: MethodFn + patch: MethodFn + post: MethodFn + put: MethodFn + request: RequestFn + setConfig: (config: Config) => Config + trace: MethodFn +} + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit["headers"] + | Record + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: "CONNECT" | "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT" | "TRACE" + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K] +} diff --git a/packages/sdk/js/src/gen/core/utils.gen.ts b/packages/sdk/js/src/gen/core/utils.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..be18c608a5df6a63fa1e56d14e855c00e0e9654b --- /dev/null +++ b/packages/sdk/js/src/gen/core/utils.gen.ts @@ -0,0 +1,109 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { QuerySerializer } from "./bodySerializer.gen.js" +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "./pathSerializer.gen.js" + +export interface PathSerializer { + path: Record + url: string +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url + const matches = _url.match(PATH_PARAM_RE) + if (matches) { + for (const match of matches) { + let explode = false + let name = match.substring(1, match.length - 1) + let style: ArraySeparatorStyle = "simple" + + if (name.endsWith("*")) { + explode = true + name = name.substring(0, name.length - 1) + } + + if (name.startsWith(".")) { + name = name.substring(1) + style = "label" + } else if (name.startsWith(";")) { + name = name.substring(1) + style = "matrix" + } + + const value = path[name] + + if (value === undefined || value === null) { + continue + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })) + continue + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ) + continue + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ) + continue + } + + const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string)) + url = url.replace(match, replaceValue) + } + } + return url +} + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string + path?: Record + query?: Record + querySerializer: QuerySerializer + url: string +}) => { + const pathUrl = _url.startsWith("/") ? _url : `/${_url}` + let url = (baseUrl ?? "") + pathUrl + if (path) { + url = defaultPathSerializer({ path, url }) + } + let search = query ? querySerializer(query) : "" + if (search.startsWith("?")) { + search = search.substring(1) + } + if (search) { + url += `?${search}` + } + return url +} diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e3e67e1c03080d0e31c8ca362a8e9293ffe1324 --- /dev/null +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -0,0 +1,1197 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Options as ClientOptions, TDataShape, Client } from "./client/index.js" +import type { + GlobalEventData, + GlobalEventResponses, + ProjectListData, + ProjectListResponses, + ProjectCurrentData, + ProjectCurrentResponses, + PtyListData, + PtyListResponses, + PtyCreateData, + PtyCreateResponses, + PtyCreateErrors, + PtyRemoveData, + PtyRemoveResponses, + PtyRemoveErrors, + PtyGetData, + PtyGetResponses, + PtyGetErrors, + PtyUpdateData, + PtyUpdateResponses, + PtyUpdateErrors, + PtyConnectData, + PtyConnectResponses, + PtyConnectErrors, + ConfigGetData, + ConfigGetResponses, + ConfigUpdateData, + ConfigUpdateResponses, + ConfigUpdateErrors, + ToolIdsData, + ToolIdsResponses, + ToolIdsErrors, + ToolListData, + ToolListResponses, + ToolListErrors, + InstanceDisposeData, + InstanceDisposeResponses, + PathGetData, + PathGetResponses, + VcsGetData, + VcsGetResponses, + SessionListData, + SessionListResponses, + SessionCreateData, + SessionCreateResponses, + SessionCreateErrors, + SessionStatusData, + SessionStatusResponses, + SessionStatusErrors, + SessionDeleteData, + SessionDeleteResponses, + SessionDeleteErrors, + SessionGetData, + SessionGetResponses, + SessionGetErrors, + SessionUpdateData, + SessionUpdateResponses, + SessionUpdateErrors, + SessionChildrenData, + SessionChildrenResponses, + SessionChildrenErrors, + SessionTodoData, + SessionTodoResponses, + SessionTodoErrors, + SessionInitData, + SessionInitResponses, + SessionInitErrors, + SessionForkData, + SessionForkResponses, + SessionAbortData, + SessionAbortResponses, + SessionAbortErrors, + SessionUnshareData, + SessionUnshareResponses, + SessionUnshareErrors, + SessionShareData, + SessionShareResponses, + SessionShareErrors, + SessionDiffData, + SessionDiffResponses, + SessionDiffErrors, + SessionSummarizeData, + SessionSummarizeResponses, + SessionSummarizeErrors, + SessionMessagesData, + SessionMessagesResponses, + SessionMessagesErrors, + SessionPromptData, + SessionPromptResponses, + SessionPromptErrors, + SessionMessageData, + SessionMessageResponses, + SessionMessageErrors, + SessionPromptAsyncData, + SessionPromptAsyncResponses, + SessionPromptAsyncErrors, + SessionCommandData, + SessionCommandResponses, + SessionCommandErrors, + SessionShellData, + SessionShellResponses, + SessionShellErrors, + SessionRevertData, + SessionRevertResponses, + SessionRevertErrors, + SessionUnrevertData, + SessionUnrevertResponses, + SessionUnrevertErrors, + PostSessionIdPermissionsPermissionIdData, + PostSessionIdPermissionsPermissionIdResponses, + PostSessionIdPermissionsPermissionIdErrors, + CommandListData, + CommandListResponses, + ConfigProvidersData, + ConfigProvidersResponses, + ProviderListData, + ProviderListResponses, + ProviderAuthData, + ProviderAuthResponses, + ProviderOauthAuthorizeData, + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ProviderOauthCallbackData, + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + FindTextData, + FindTextResponses, + FindFilesData, + FindFilesResponses, + FindSymbolsData, + FindSymbolsResponses, + FileListData, + FileListResponses, + FileReadData, + FileReadResponses, + FileStatusData, + FileStatusResponses, + AppLogData, + AppLogResponses, + AppLogErrors, + AppAgentsData, + AppAgentsResponses, + McpStatusData, + McpStatusResponses, + McpAddData, + McpAddResponses, + McpAddErrors, + McpAuthRemoveData, + McpAuthRemoveResponses, + McpAuthRemoveErrors, + McpAuthStartData, + McpAuthStartResponses, + McpAuthStartErrors, + McpAuthCallbackData, + McpAuthCallbackResponses, + McpAuthCallbackErrors, + McpAuthAuthenticateData, + McpAuthAuthenticateResponses, + McpAuthAuthenticateErrors, + McpConnectData, + McpConnectResponses, + McpDisconnectData, + McpDisconnectResponses, + LspStatusData, + LspStatusResponses, + FormatterStatusData, + FormatterStatusResponses, + TuiAppendPromptData, + TuiAppendPromptResponses, + TuiAppendPromptErrors, + TuiOpenHelpData, + TuiOpenHelpResponses, + TuiOpenSessionsData, + TuiOpenSessionsResponses, + TuiOpenThemesData, + TuiOpenThemesResponses, + TuiOpenModelsData, + TuiOpenModelsResponses, + TuiSubmitPromptData, + TuiSubmitPromptResponses, + TuiClearPromptData, + TuiClearPromptResponses, + TuiExecuteCommandData, + TuiExecuteCommandResponses, + TuiExecuteCommandErrors, + TuiShowToastData, + TuiShowToastResponses, + TuiPublishData, + TuiPublishResponses, + TuiPublishErrors, + TuiControlNextData, + TuiControlNextResponses, + TuiControlResponseData, + TuiControlResponseResponses, + AuthSetData, + AuthSetResponses, + AuthSetErrors, + EventSubscribeData, + EventSubscribeResponses, +} from "./types.gen.js" +import { client as _heyApiClient } from "./client.gen.js" + +export type Options = ClientOptions< + TData, + ThrowOnError +> & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record +} + +class _HeyApiClient { + protected _client: Client = _heyApiClient + + constructor(args?: { client?: Client }) { + if (args?.client) { + this._client = args.client + } + } +} + +class Global extends _HeyApiClient { + /** + * Get events + */ + public event(options?: Options) { + return (options?.client ?? this._client).get.sse({ + url: "/global/event", + ...options, + }) + } +} + +class Project extends _HeyApiClient { + /** + * List all projects + */ + public list(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/project", + ...options, + }) + } + + /** + * Get the current project + */ + public current(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/project/current", + ...options, + }) + } +} + +class Pty extends _HeyApiClient { + /** + * List all PTY sessions + */ + public list(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/pty", + ...options, + }) + } + + /** + * Create a new PTY session + */ + public create(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/pty", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * Remove a PTY session + */ + public remove(options: Options) { + return (options.client ?? this._client).delete({ + url: "/pty/{id}", + ...options, + }) + } + + /** + * Get PTY session info + */ + public get(options: Options) { + return (options.client ?? this._client).get({ + url: "/pty/{id}", + ...options, + }) + } + + /** + * Update PTY session + */ + public update(options: Options) { + return (options.client ?? this._client).put({ + url: "/pty/{id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Connect to a PTY session + */ + public connect(options: Options) { + return (options.client ?? this._client).get({ + url: "/pty/{id}/connect", + ...options, + }) + } +} + +class Config extends _HeyApiClient { + /** + * Get config info + */ + public get(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/config", + ...options, + }) + } + + /** + * Update config + */ + public update(options?: Options) { + return (options?.client ?? this._client).patch({ + url: "/config", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * List all providers + */ + public providers(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/config/providers", + ...options, + }) + } +} + +class Tool extends _HeyApiClient { + /** + * List all tool IDs (including built-in and dynamically registered) + */ + public ids(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/experimental/tool/ids", + ...options, + }) + } + + /** + * List tools with JSON schema parameters for a provider/model + */ + public list(options: Options) { + return (options.client ?? this._client).get({ + url: "/experimental/tool", + ...options, + }) + } +} + +class Instance extends _HeyApiClient { + /** + * Dispose the current instance + */ + public dispose(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/instance/dispose", + ...options, + }) + } +} + +class Path extends _HeyApiClient { + /** + * Get the current path + */ + public get(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/path", + ...options, + }) + } +} + +class Vcs extends _HeyApiClient { + /** + * Get VCS info for the current instance + */ + public get(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/vcs", + ...options, + }) + } +} + +class Session extends _HeyApiClient { + /** + * List all sessions + */ + public list(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/session", + ...options, + }) + } + + /** + * Create a new session + */ + public create(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/session", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * Get session status + */ + public status(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/session/status", + ...options, + }) + } + + /** + * Delete a session and all its data + */ + public delete(options: Options) { + return (options.client ?? this._client).delete({ + url: "/session/{id}", + ...options, + }) + } + + /** + * Get session + */ + public get(options: Options) { + return (options.client ?? this._client).get({ + url: "/session/{id}", + ...options, + }) + } + + /** + * Update session properties + */ + public update(options: Options) { + return (options.client ?? this._client).patch({ + url: "/session/{id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Get a session's children + */ + public children(options: Options) { + return (options.client ?? this._client).get({ + url: "/session/{id}/children", + ...options, + }) + } + + /** + * Get the todo list for a session + */ + public todo(options: Options) { + return (options.client ?? this._client).get({ + url: "/session/{id}/todo", + ...options, + }) + } + + /** + * Analyze the app and create an AGENTS.md file + */ + public init(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/init", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Fork an existing session at a specific message + */ + public fork(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/fork", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Abort a session + */ + public abort(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/abort", + ...options, + }) + } + + /** + * Unshare the session + */ + public unshare(options: Options) { + return (options.client ?? this._client).delete({ + url: "/session/{id}/share", + ...options, + }) + } + + /** + * Share a session + */ + public share(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/share", + ...options, + }) + } + + /** + * Get the diff for this session + */ + public diff(options: Options) { + return (options.client ?? this._client).get({ + url: "/session/{id}/diff", + ...options, + }) + } + + /** + * Summarize the session + */ + public summarize(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/summarize", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * List messages for a session + */ + public messages(options: Options) { + return (options.client ?? this._client).get({ + url: "/session/{id}/message", + ...options, + }) + } + + /** + * Create and send a new message to a session + */ + public prompt(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/message", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Get a message from a session + */ + public message(options: Options) { + return (options.client ?? this._client).get({ + url: "/session/{id}/message/{messageID}", + ...options, + }) + } + + /** + * Create and send a new message to a session, start if needed and return immediately + */ + public promptAsync(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/prompt_async", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Send a new command to a session + */ + public command(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/command", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Run a shell command + */ + public shell(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/shell", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Revert a message + */ + public revert(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/revert", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Restore all reverted messages + */ + public unrevert(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/unrevert", + ...options, + }) + } +} + +class Command extends _HeyApiClient { + /** + * List all commands + */ + public list(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/command", + ...options, + }) + } +} + +class Oauth extends _HeyApiClient { + /** + * Authorize a provider using OAuth + */ + public authorize(options: Options) { + return (options.client ?? this._client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{id}/oauth/authorize", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Handle OAuth callback for a provider + */ + public callback(options: Options) { + return (options.client ?? this._client).post< + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + ThrowOnError + >({ + url: "/provider/{id}/oauth/callback", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } +} + +class Provider extends _HeyApiClient { + /** + * List all providers + */ + public list(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/provider", + ...options, + }) + } + + /** + * Get provider authentication methods + */ + public auth(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/provider/auth", + ...options, + }) + } + oauth = new Oauth({ client: this._client }) +} + +class Find extends _HeyApiClient { + /** + * Find text in files + */ + public text(options: Options) { + return (options.client ?? this._client).get({ + url: "/find", + ...options, + }) + } + + /** + * Find files + */ + public files(options: Options) { + return (options.client ?? this._client).get({ + url: "/find/file", + ...options, + }) + } + + /** + * Find workspace symbols + */ + public symbols(options: Options) { + return (options.client ?? this._client).get({ + url: "/find/symbol", + ...options, + }) + } +} + +class File extends _HeyApiClient { + /** + * List files and directories + */ + public list(options: Options) { + return (options.client ?? this._client).get({ + url: "/file", + ...options, + }) + } + + /** + * Read a file + */ + public read(options: Options) { + return (options.client ?? this._client).get({ + url: "/file/content", + ...options, + }) + } + + /** + * Get file status + */ + public status(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/file/status", + ...options, + }) + } +} + +class App extends _HeyApiClient { + /** + * Write a log entry to the server logs + */ + public log(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/log", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * List all agents + */ + public agents(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/agent", + ...options, + }) + } +} + +class Auth extends _HeyApiClient { + /** + * Remove OAuth credentials for an MCP server + */ + public remove(options: Options) { + return (options.client ?? this._client).delete({ + url: "/mcp/{name}/auth", + ...options, + }) + } + + /** + * Start OAuth authentication flow for an MCP server + */ + public start(options: Options) { + return (options.client ?? this._client).post({ + url: "/mcp/{name}/auth", + ...options, + }) + } + + /** + * Complete OAuth authentication with authorization code + */ + public callback(options: Options) { + return (options.client ?? this._client).post({ + url: "/mcp/{name}/auth/callback", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Start OAuth flow and wait for callback (opens browser) + */ + public authenticate(options: Options) { + return (options.client ?? this._client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + }, + ) + } + + /** + * Set authentication credentials + */ + public set(options: Options) { + return (options.client ?? this._client).put({ + url: "/auth/{id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } +} + +class Mcp extends _HeyApiClient { + /** + * Get MCP server status + */ + public status(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/mcp", + ...options, + }) + } + + /** + * Add MCP server dynamically + */ + public add(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/mcp", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * Connect an MCP server + */ + public connect(options: Options) { + return (options.client ?? this._client).post({ + url: "/mcp/{name}/connect", + ...options, + }) + } + + /** + * Disconnect an MCP server + */ + public disconnect(options: Options) { + return (options.client ?? this._client).post({ + url: "/mcp/{name}/disconnect", + ...options, + }) + } + + auth = new Auth({ client: this._client }) +} + +class Lsp extends _HeyApiClient { + /** + * Get LSP server status + */ + public status(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/lsp", + ...options, + }) + } +} + +class Formatter extends _HeyApiClient { + /** + * Get formatter status + */ + public status(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/formatter", + ...options, + }) + } +} + +class Control extends _HeyApiClient { + /** + * Get the next TUI request from the queue + */ + public next(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/tui/control/next", + ...options, + }) + } + + /** + * Submit a response to the TUI request queue + */ + public response(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/control/response", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } +} + +class Tui extends _HeyApiClient { + /** + * Append prompt to the TUI + */ + public appendPrompt(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/append-prompt", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * Open the help dialog + */ + public openHelp(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/open-help", + ...options, + }) + } + + /** + * Open the session dialog + */ + public openSessions(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/open-sessions", + ...options, + }) + } + + /** + * Open the theme dialog + */ + public openThemes(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/open-themes", + ...options, + }) + } + + /** + * Open the model dialog + */ + public openModels(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/open-models", + ...options, + }) + } + + /** + * Submit the prompt + */ + public submitPrompt(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/submit-prompt", + ...options, + }) + } + + /** + * Clear the prompt + */ + public clearPrompt(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/clear-prompt", + ...options, + }) + } + + /** + * Execute a TUI command (e.g. agent_cycle) + */ + public executeCommand(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/execute-command", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * Show a toast notification in the TUI + */ + public showToast(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/show-toast", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * Publish a TUI event + */ + public publish(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/tui/publish", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + control = new Control({ client: this._client }) +} + +class Event extends _HeyApiClient { + /** + * Get events + */ + public subscribe(options?: Options) { + return (options?.client ?? this._client).get.sse({ + url: "/event", + ...options, + }) + } +} + +export class OpencodeClient extends _HeyApiClient { + /** + * Respond to a permission request + */ + public postSessionIdPermissionsPermissionId( + options: Options, + ) { + return (options.client ?? this._client).post< + PostSessionIdPermissionsPermissionIdResponses, + PostSessionIdPermissionsPermissionIdErrors, + ThrowOnError + >({ + url: "/session/{id}/permissions/{permissionID}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + global = new Global({ client: this._client }) + project = new Project({ client: this._client }) + pty = new Pty({ client: this._client }) + config = new Config({ client: this._client }) + tool = new Tool({ client: this._client }) + instance = new Instance({ client: this._client }) + path = new Path({ client: this._client }) + vcs = new Vcs({ client: this._client }) + session = new Session({ client: this._client }) + command = new Command({ client: this._client }) + provider = new Provider({ client: this._client }) + find = new Find({ client: this._client }) + file = new File({ client: this._client }) + app = new App({ client: this._client }) + mcp = new Mcp({ client: this._client }) + lsp = new Lsp({ client: this._client }) + formatter = new Formatter({ client: this._client }) + tui = new Tui({ client: this._client }) + auth = new Auth({ client: this._client }) + event = new Event({ client: this._client }) +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e4fd8906155b9c88d29cda1c44adafcb09b4aaf --- /dev/null +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -0,0 +1,3907 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" + properties: { + directory: string + } +} + +export type EventInstallationUpdated = { + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable = { + type: "installation.update-available" + properties: { + version: string + } +} + +export type EventLspClientDiagnostics = { + type: "lsp.client.diagnostics" + properties: { + serverID: string + path: string + } +} + +export type EventLspUpdated = { + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +export type FileDiff = { + file: string + before: string + after: string + additions: number + deletions: number +} + +export type UserMessage = { + id: string + sessionID: string + role: "user" + time: { + created: number + } + summary?: { + title?: string + body?: string + diffs: Array + } + agent: string + model: { + providerID: string + modelID: string + } + system?: string + tools?: { + [key: string]: boolean + } +} + +export type ProviderAuthError = { + name: "ProviderAuthError" + data: { + providerID: string + message: string + } +} + +export type UnknownError = { + name: "UnknownError" + data: { + message: string + } +} + +export type MessageOutputLengthError = { + name: "MessageOutputLengthError" + data: { + [key: string]: unknown + } +} + +export type MessageAbortedError = { + name: "MessageAbortedError" + data: { + message: string + } +} + +export type ApiError = { + name: "APIError" + data: { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + } +} + +export type AssistantMessage = { + id: string + sessionID: string + role: "assistant" + time: { + created: number + completed?: number + } + error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError + parentID: string + modelID: string + providerID: string + mode: string + path: { + cwd: string + root: string + } + summary?: boolean + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + finish?: string +} + +export type Message = UserMessage | AssistantMessage + +export type EventMessageUpdated = { + type: "message.updated" + properties: { + info: Message + } +} + +export type EventMessageRemoved = { + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type TextPart = { + id: string + sessionID: string + messageID: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type ReasoningPart = { + id: string + sessionID: string + messageID: string + type: "reasoning" + text: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + end?: number + } +} + +export type FilePartSourceText = { + value: string + start: number + end: number +} + +export type FileSource = { + text: FilePartSourceText + type: "file" + path: string +} + +export type Range = { + start: { + line: number + character: number + } + end: { + line: number + character: number + } +} + +export type SymbolSource = { + text: FilePartSourceText + type: "symbol" + path: string + range: Range + name: string + kind: number +} + +export type FilePartSource = FileSource | SymbolSource + +export type FilePart = { + id: string + sessionID: string + messageID: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type ToolStatePending = { + status: "pending" + input: { + [key: string]: unknown + } + raw: string +} + +export type ToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + title?: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + } +} + +export type ToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + output: string + title: string + metadata: { + [key: string]: unknown + } + time: { + start: number + end: number + compacted?: number + } + attachments?: Array +} + +export type ToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + error: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + end: number + } +} + +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export type ToolPart = { + id: string + sessionID: string + messageID: string + type: "tool" + callID: string + tool: string + state: ToolState + metadata?: { + [key: string]: unknown + } +} + +export type StepStartPart = { + id: string + sessionID: string + messageID: string + type: "step-start" + snapshot?: string +} + +export type StepFinishPart = { + id: string + sessionID: string + messageID: string + type: "step-finish" + reason: string + snapshot?: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } +} + +export type SnapshotPart = { + id: string + sessionID: string + messageID: string + type: "snapshot" + snapshot: string +} + +export type PatchPart = { + id: string + sessionID: string + messageID: string + type: "patch" + hash: string + files: Array +} + +export type AgentPart = { + id: string + sessionID: string + messageID: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type RetryPart = { + id: string + sessionID: string + messageID: string + type: "retry" + attempt: number + error: ApiError + time: { + created: number + } +} + +export type CompactionPart = { + id: string + sessionID: string + messageID: string + type: "compaction" + auto: boolean +} + +export type Part = + | TextPart + | { + id: string + sessionID: string + messageID: string + type: "subtask" + prompt: string + description: string + agent: string + } + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +export type EventMessagePartUpdated = { + type: "message.part.updated" + properties: { + part: Part + delta?: string + } +} + +export type EventMessagePartRemoved = { + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type Permission = { + id: string + type: string + pattern?: string | Array + sessionID: string + messageID: string + callID?: string + title: string + metadata: { + [key: string]: unknown + } + time: { + created: number + } +} + +export type EventPermissionUpdated = { + type: "permission.updated" + properties: Permission +} + +export type EventPermissionReplied = { + type: "permission.replied" + properties: { + sessionID: string + permissionID: string + response: string + } +} + +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionCompacted = { + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string + /** + * Unique identifier for the todo item + */ + id: string +} + +export type EventTodoUpdated = { + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventCommandExecuted = { + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type Session = { + id: string + projectID: string + directory: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + share?: { + url: string + } + title: string + version: string + time: { + created: number + updated: number + compacting?: number + } + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type EventSessionCreated = { + type: "session.created" + properties: { + info: Session + } +} + +export type EventSessionUpdated = { + type: "session.updated" + properties: { + info: Session + } +} + +export type EventSessionDeleted = { + type: "session.deleted" + properties: { + info: Session + } +} + +export type EventSessionDiff = { + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + type: "session.error" + properties: { + sessionID?: string + error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | ( + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + ) + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number +} + +export type EventPtyCreated = { + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventServerConnected = { + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type Event = + | EventServerInstanceDisposed + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventPermissionUpdated + | EventPermissionReplied + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventFileEdited + | EventTodoUpdated + | EventCommandExecuted + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionDiff + | EventSessionError + | EventFileWatcherUpdated + | EventVcsBranchUpdated + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventServerConnected + +export type GlobalEvent = { + directory: string + payload: Event +} + +export type Project = { + id: string + worktree: string + vcsDir?: string + vcs?: "git" + time: { + created: number + initialized?: number + } +} + +export type BadRequestError = { + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + +/** + * Custom keybind configurations + */ +export type KeybindsConfig = { + /** + * Leader key for keybind combinations + */ + leader?: string + /** + * Exit the application + */ + app_exit?: string + /** + * Open external editor + */ + editor_open?: string + /** + * List available themes + */ + theme_list?: string + /** + * Toggle sidebar + */ + sidebar_toggle?: string + /** + * Toggle session scrollbar + */ + scrollbar_toggle?: string + /** + * Toggle username visibility + */ + username_toggle?: string + /** + * View status + */ + status_view?: string + /** + * Export session to editor + */ + session_export?: string + /** + * Create a new session + */ + session_new?: string + /** + * List all sessions + */ + session_list?: string + /** + * Show session timeline + */ + session_timeline?: string + /** + * Share current session + */ + session_share?: string + /** + * Unshare current session + */ + session_unshare?: string + /** + * Interrupt current session + */ + session_interrupt?: string + /** + * Compact the session + */ + session_compact?: string + /** + * Scroll messages up by one page + */ + messages_page_up?: string + /** + * Scroll messages down by one page + */ + messages_page_down?: string + /** + * Scroll messages up by one line + */ + messages_line_up?: string + /** + * Scroll messages down by one line + */ + messages_line_down?: string + /** + * Scroll messages up by half page + */ + messages_half_page_up?: string + /** + * Scroll messages down by half page + */ + messages_half_page_down?: string + /** + * Navigate to first message + */ + messages_first?: string + /** + * Navigate to last message + */ + messages_last?: string + /** + * Navigate to next message + */ + messages_next?: string + /** + * Navigate to previous message + */ + messages_previous?: string + /** + * Navigate to last user message + */ + messages_last_user?: string + /** + * Copy message + */ + messages_copy?: string + /** + * Undo message + */ + messages_undo?: string + /** + * Redo message + */ + messages_redo?: string + /** + * Toggle code block concealment in messages + */ + messages_toggle_conceal?: string + /** + * Toggle tool details visibility + */ + tool_details?: string + /** + * List available models + */ + model_list?: string + /** + * Next recently used model + */ + model_cycle_recent?: string + /** + * Previous recently used model + */ + model_cycle_recent_reverse?: string + /** + * List available commands + */ + command_list?: string + /** + * List agents + */ + agent_list?: string + /** + * Next agent + */ + agent_cycle?: string + /** + * Previous agent + */ + agent_cycle_reverse?: string + /** + * Clear input field + */ + input_clear?: string + /** + * Forward delete + */ + input_forward_delete?: string + /** + * Paste from clipboard + */ + input_paste?: string + /** + * Submit input + */ + input_submit?: string + /** + * Insert newline in input + */ + input_newline?: string + /** + * Previous history item + */ + history_previous?: string + /** + * Next history item + */ + history_next?: string + /** + * Next child session + */ + session_child_cycle?: string + /** + * Previous child session + */ + session_child_cycle_reverse?: string + /** + * Suspend terminal + */ + terminal_suspend?: string + /** + * Toggle terminal title + */ + terminal_title_toggle?: string +} + +export type AgentConfig = { + model?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + /** + * Description of when to use the agent + */ + description?: string + mode?: "subagent" | "primary" | "all" + /** + * Hex color code for the agent (e.g., #FF5733) + */ + color?: string + /** + * Maximum number of agentic iterations before forcing text-only response + */ + maxSteps?: number + permission?: { + edit?: "ask" | "allow" | "deny" + bash?: + | ("ask" | "allow" | "deny") + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + doom_loop?: "ask" | "allow" | "deny" + external_directory?: "ask" | "allow" | "deny" + } + [key: string]: + | unknown + | string + | number + | { + [key: string]: boolean + } + | boolean + | ("subagent" | "primary" | "all") + | number + | { + edit?: "ask" | "allow" | "deny" + bash?: + | ("ask" | "allow" | "deny") + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + doom_loop?: "ask" | "allow" | "deny" + external_directory?: "ask" | "allow" | "deny" + } + | undefined +} + +export type ProviderConfig = { + api?: string + name?: string + env?: Array + id?: string + npm?: string + models?: { + [key: string]: { + id?: string + name?: string + release_date?: string + attachment?: boolean + reasoning?: boolean + temperature?: boolean + tool_call?: boolean + cost?: { + input: number + output: number + cache_read?: number + cache_write?: number + context_over_200k?: { + input: number + output: number + cache_read?: number + cache_write?: number + } + } + limit?: { + context: number + output: number + } + modalities?: { + input: Array<"text" | "audio" | "image" | "video" | "pdf"> + output: Array<"text" | "audio" | "image" | "video" | "pdf"> + } + experimental?: boolean + status?: "alpha" | "beta" | "deprecated" | "active" + options?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + provider?: { + npm: string + } + } + } + whitelist?: Array + blacklist?: Array + options?: { + apiKey?: string + baseURL?: string + /** + * GitHub Enterprise URL for copilot authentication + */ + enterpriseUrl?: string + /** + * Enable promptCacheKey for this provider (default false) + */ + setCacheKey?: boolean + /** + * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. + */ + timeout?: number | false + [key: string]: unknown | string | boolean | (number | false) | undefined + } +} + +export type McpLocalConfig = { + /** + * Type of MCP server connection + */ + type: "local" + /** + * Command and arguments to run the MCP server + */ + command: Array + /** + * Environment variables to set when running the MCP server + */ + environment?: { + [key: string]: string + } + /** + * Enable or disable the MCP server on startup + */ + enabled?: boolean + /** + * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. + */ + timeout?: number +} + +export type McpOAuthConfig = { + /** + * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. + */ + clientId?: string + /** + * OAuth client secret (if required by the authorization server) + */ + clientSecret?: string + /** + * OAuth scopes to request during authorization + */ + scope?: string +} + +export type McpRemoteConfig = { + /** + * Type of MCP server connection + */ + type: "remote" + /** + * URL of the remote MCP server + */ + url: string + /** + * Enable or disable the MCP server on startup + */ + enabled?: boolean + /** + * Headers to send with the request + */ + headers?: { + [key: string]: string + } + /** + * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. + */ + oauth?: McpOAuthConfig | false + /** + * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. + */ + timeout?: number +} + +/** + * @deprecated Always uses stretch layout. + */ +export type LayoutConfig = "auto" | "stretch" + +export type Config = { + /** + * JSON schema reference for configuration validation + */ + $schema?: string + /** + * Theme name to use for the interface + */ + theme?: string + keybinds?: KeybindsConfig + /** + * Log level + */ + logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR" + /** + * TUI specific settings + */ + tui?: { + /** + * TUI scroll speed + */ + scroll_speed?: number + /** + * Scroll acceleration settings + */ + scroll_acceleration?: { + /** + * Enable scroll acceleration + */ + enabled: boolean + } + /** + * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column + */ + diff_style?: "auto" | "stacked" + } + /** + * Command configuration, see https://opencode.ai/docs/commands + */ + command?: { + [key: string]: { + template: string + description?: string + agent?: string + model?: string + subtask?: boolean + } + } + watcher?: { + ignore?: Array + } + plugin?: Array + snapshot?: boolean + /** + * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing + */ + share?: "manual" | "auto" | "disabled" + /** + * @deprecated Use 'share' field instead. Share newly created sessions automatically + */ + autoshare?: boolean + /** + * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications + */ + autoupdate?: boolean | "notify" + /** + * Disable providers that are loaded automatically + */ + disabled_providers?: Array + /** + * When set, ONLY these providers will be enabled. All other providers will be ignored + */ + enabled_providers?: Array + /** + * Model to use in the format of provider/model, eg anthropic/claude-2 + */ + model?: string + /** + * Small model to use for tasks like title generation in the format of provider/model + */ + small_model?: string + /** + * Custom username to display in conversations instead of system username + */ + username?: string + /** + * @deprecated Use `agent` field instead. + */ + mode?: { + build?: AgentConfig + plan?: AgentConfig + [key: string]: AgentConfig | undefined + } + /** + * Agent configuration, see https://opencode.ai/docs/agent + */ + agent?: { + plan?: AgentConfig + build?: AgentConfig + general?: AgentConfig + explore?: AgentConfig + [key: string]: AgentConfig | undefined + } + /** + * Custom provider configurations and model overrides + */ + provider?: { + [key: string]: ProviderConfig + } + /** + * MCP (Model Context Protocol) server configurations + */ + mcp?: { + [key: string]: McpLocalConfig | McpRemoteConfig + } + formatter?: + | false + | { + [key: string]: { + disabled?: boolean + command?: Array + environment?: { + [key: string]: string + } + extensions?: Array + } + } + lsp?: + | false + | { + [key: string]: + | { + disabled: true + } + | { + command: Array + extensions?: Array + disabled?: boolean + env?: { + [key: string]: string + } + initialization?: { + [key: string]: unknown + } + } + } + /** + * Additional instruction files or patterns to include + */ + instructions?: Array + layout?: LayoutConfig + permission?: { + edit?: "ask" | "allow" | "deny" + bash?: + | ("ask" | "allow" | "deny") + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + doom_loop?: "ask" | "allow" | "deny" + external_directory?: "ask" | "allow" | "deny" + } + tools?: { + [key: string]: boolean + } + enterprise?: { + /** + * Enterprise URL + */ + url?: string + } + experimental?: { + hook?: { + file_edited?: { + [key: string]: Array<{ + command: Array + environment?: { + [key: string]: string + } + }> + } + session_completed?: Array<{ + command: Array + environment?: { + [key: string]: string + } + }> + } + /** + * Number of retries for chat completions on failure + */ + chatMaxRetries?: number + disable_paste_summary?: boolean + /** + * Enable the batch tool + */ + batch_tool?: boolean + /** + * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) + */ + openTelemetry?: boolean + /** + * Tools that should only be available to primary agents. + */ + primary_tools?: Array + } +} + +export type ToolIds = Array + +export type ToolListItem = { + id: string + description: string + parameters: unknown +} + +export type ToolList = Array + +export type Path = { + state: string + config: string + worktree: string + directory: string +} + +export type VcsInfo = { + branch: string +} + +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string +} + +export type Command = { + name: string + description?: string + agent?: string + model?: string + template: string + subtask?: boolean +} + +export type Model = { + id: string + providerID: string + api: { + id: string + url: string + npm: string + } + name: string + capabilities: { + temperature: boolean + reasoning: boolean + attachment: boolean + toolcall: boolean + input: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + output: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + } + cost: { + input: number + output: number + cache: { + read: number + write: number + } + experimentalOver200K?: { + input: number + output: number + cache: { + read: number + write: number + } + } + } + limit: { + context: number + output: number + } + status: "alpha" | "beta" | "deprecated" | "active" + options: { + [key: string]: unknown + } + headers: { + [key: string]: string + } +} + +export type Provider = { + id: string + name: string + source: "env" | "config" | "custom" | "api" + env: Array + key?: string + options: { + [key: string]: unknown + } + models: { + [key: string]: Model + } +} + +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export type Symbol = { + name: string + kind: number + location: { + uri: string + range: Range + } +} + +export type FileNode = { + name: string + path: string + absolute: string + type: "file" | "directory" + ignored: boolean +} + +export type FileContent = { + type: "text" | "binary" + content: string + diff?: string + patch?: { + oldFileName: string + newFileName: string + oldHeader?: string + newHeader?: string + hunks: Array<{ + oldStart: number + oldLines: number + newStart: number + newLines: number + lines: Array + }> + index?: string + } + encoding?: "base64" + mimeType?: string +} + +export type File = { + path: string + added: number + removed: number + status: "added" | "deleted" | "modified" +} + +export type Agent = { + name: string + description?: string + mode: "subagent" | "primary" | "all" + builtIn: boolean + topP?: number + temperature?: number + color?: string + permission: { + edit: "ask" | "allow" | "deny" + bash: { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + doom_loop?: "ask" | "allow" | "deny" + external_directory?: "ask" | "allow" | "deny" + } + model?: { + modelID: string + providerID: string + } + prompt?: string + tools: { + [key: string]: boolean + } + options: { + [key: string]: unknown + } + maxSteps?: number +} + +export type McpStatusConnected = { + status: "connected" +} + +export type McpStatusDisabled = { + status: "disabled" +} + +export type McpStatusFailed = { + status: "failed" + error: string +} + +export type McpStatusNeedsAuth = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration" + error: string +} + +export type McpStatus = + | McpStatusConnected + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration + +export type LspStatus = { + id: string + name: string + root: string + status: "connected" | "error" +} + +export type FormatterStatus = { + name: string + extensions: Array + enabled: boolean +} + +export type OAuth = { + type: "oauth" + refresh: string + access: string + expires: number + enterpriseUrl?: string +} + +export type ApiAuth = { + type: "api" + key: string + metadata?: { + [key: string]: string + } +} + +export type WellKnownAuth = { + type: "wellknown" + key: string + token: string +} + +export type Auth = OAuth | ApiAuth | WellKnownAuth + +export type GlobalEventData = { + body?: never + path?: never + query?: never + url: "/global/event" +} + +export type GlobalEventResponses = { + /** + * Event stream + */ + 200: GlobalEvent +} + +export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses] + +export type ProjectListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/project" +} + +export type ProjectListResponses = { + /** + * List of projects + */ + 200: Array +} + +export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] + +export type ProjectCurrentData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/project/current" +} + +export type ProjectCurrentResponses = { + /** + * Current project + */ + 200: Project +} + +export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] + +export type PtyListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/pty" +} + +export type PtyListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type PtyListResponse = PtyListResponses[keyof PtyListResponses] + +export type PtyCreateData = { + body?: { + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + } + path?: never + query?: { + directory?: string + } + url: "/pty" +} + +export type PtyCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors] + +export type PtyCreateResponses = { + /** + * Created session + */ + 200: Pty +} + +export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses] + +export type PtyRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/pty/{id}" +} + +export type PtyRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] + +export type PtyRemoveResponses = { + /** + * Session removed + */ + 200: boolean +} + +export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses] + +export type PtyGetData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/pty/{id}" +} + +export type PtyGetErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] + +export type PtyGetResponses = { + /** + * Session info + */ + 200: Pty +} + +export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] + +export type PtyUpdateData = { + body?: { + title?: string + size?: { + rows: number + cols: number + } + } + path: { + id: string + } + query?: { + directory?: string + } + url: "/pty/{id}" +} + +export type PtyUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] + +export type PtyUpdateResponses = { + /** + * Updated session + */ + 200: Pty +} + +export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] + +export type PtyConnectData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/pty/{id}/connect" +} + +export type PtyConnectErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] + +export type PtyConnectResponses = { + /** + * Connected session + */ + 200: boolean +} + +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] + +export type ConfigGetData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/config" +} + +export type ConfigGetResponses = { + /** + * Get config info + */ + 200: Config +} + +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] + +export type ConfigUpdateData = { + body?: Config + path?: never + query?: { + directory?: string + } + url: "/config" +} + +export type ConfigUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] + +export type ConfigUpdateResponses = { + /** + * Successfully updated config + */ + 200: Config +} + +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] + +export type ToolIdsData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/experimental/tool/ids" +} + +export type ToolIdsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] + +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds +} + +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] + +export type ToolListData = { + body?: never + path?: never + query: { + directory?: string + provider: string + model: string + } + url: "/experimental/tool" +} + +export type ToolListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolListError = ToolListErrors[keyof ToolListErrors] + +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList +} + +export type ToolListResponse = ToolListResponses[keyof ToolListResponses] + +export type InstanceDisposeData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/instance/dispose" +} + +export type InstanceDisposeResponses = { + /** + * Instance disposed + */ + 200: boolean +} + +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] + +export type PathGetData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/path" +} + +export type PathGetResponses = { + /** + * Path + */ + 200: Path +} + +export type PathGetResponse = PathGetResponses[keyof PathGetResponses] + +export type VcsGetData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/vcs" +} + +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo +} + +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] + +export type SessionListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/session" +} + +export type SessionListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type SessionListResponse = SessionListResponses[keyof SessionListResponses] + +export type SessionCreateData = { + body?: { + parentID?: string + title?: string + } + path?: never + query?: { + directory?: string + } + url: "/session" +} + +export type SessionCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors] + +export type SessionCreateResponses = { + /** + * Successfully created session + */ + 200: Session +} + +export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] + +export type SessionStatusData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/session/status" +} + +export type SessionStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors] + +export type SessionStatusResponses = { + /** + * Get session status + */ + 200: { + [key: string]: SessionStatus + } +} + +export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses] + +export type SessionDeleteData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}" +} + +export type SessionDeleteErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors] + +export type SessionDeleteResponses = { + /** + * Successfully deleted session + */ + 200: boolean +} + +export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses] + +export type SessionGetData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}" +} + +export type SessionGetErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionGetError = SessionGetErrors[keyof SessionGetErrors] + +export type SessionGetResponses = { + /** + * Get session + */ + 200: Session +} + +export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] + +export type SessionUpdateData = { + body?: { + title?: string + } + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}" +} + +export type SessionUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors] + +export type SessionUpdateResponses = { + /** + * Successfully updated session + */ + 200: Session +} + +export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses] + +export type SessionChildrenData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/children" +} + +export type SessionChildrenErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors] + +export type SessionChildrenResponses = { + /** + * List of children + */ + 200: Array +} + +export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] + +export type SessionTodoData = { + body?: never + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/todo" +} + +export type SessionTodoErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors] + +export type SessionTodoResponses = { + /** + * Todo list + */ + 200: Array +} + +export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] + +export type SessionInitData = { + body?: { + modelID: string + providerID: string + messageID: string + } + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/init" +} + +export type SessionInitErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] + +export type SessionInitResponses = { + /** + * 200 + */ + 200: boolean +} + +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] + +export type SessionForkData = { + body?: { + messageID?: string + } + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/fork" +} + +export type SessionForkResponses = { + /** + * 200 + */ + 200: Session +} + +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] + +export type SessionAbortData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/abort" +} + +export type SessionAbortErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] + +export type SessionAbortResponses = { + /** + * Aborted session + */ + 200: boolean +} + +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] + +export type SessionUnshareData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/share" +} + +export type SessionUnshareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] + +export type SessionUnshareResponses = { + /** + * Successfully unshared session + */ + 200: Session +} + +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] + +export type SessionShareData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/share" +} + +export type SessionShareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] + +export type SessionShareResponses = { + /** + * Successfully shared session + */ + 200: Session +} + +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] + +export type SessionDiffData = { + body?: never + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + messageID?: string + } + url: "/session/{id}/diff" +} + +export type SessionDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors] + +export type SessionDiffResponses = { + /** + * List of diffs + */ + 200: Array +} + +export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] + +export type SessionSummarizeData = { + body?: { + providerID: string + modelID: string + } + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/summarize" +} + +export type SessionSummarizeErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] + +export type SessionSummarizeResponses = { + /** + * Summarized session + */ + 200: boolean +} + +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] + +export type SessionMessagesData = { + body?: never + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + limit?: number + } + url: "/session/{id}/message" +} + +export type SessionMessagesErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors] + +export type SessionMessagesResponses = { + /** + * List of messages + */ + 200: Array<{ + info: Message + parts: Array + }> +} + +export type SessionMessagesResponse = SessionMessagesResponses[keyof SessionMessagesResponses] + +export type SessionPromptData = { + body?: { + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + system?: string + tools?: { + [key: string]: boolean + } + parts: Array + } + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/message" +} + +export type SessionPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors] + +export type SessionPromptResponses = { + /** + * Created message + */ + 200: { + info: AssistantMessage + parts: Array + } +} + +export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses] + +export type SessionMessageData = { + body?: never + path: { + /** + * Session ID + */ + id: string + /** + * Message ID + */ + messageID: string + } + query?: { + directory?: string + } + url: "/session/{id}/message/{messageID}" +} + +export type SessionMessageErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors] + +export type SessionMessageResponses = { + /** + * Message + */ + 200: { + info: Message + parts: Array + } +} + +export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] + +export type SessionPromptAsyncData = { + body?: { + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + system?: string + tools?: { + [key: string]: boolean + } + parts: Array + } + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/prompt_async" +} + +export type SessionPromptAsyncErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors] + +export type SessionPromptAsyncResponses = { + /** + * Prompt accepted + */ + 204: void +} + +export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses] + +export type SessionCommandData = { + body?: { + messageID?: string + agent?: string + model?: string + arguments: string + command: string + } + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/command" +} + +export type SessionCommandErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors] + +export type SessionCommandResponses = { + /** + * Created message + */ + 200: { + info: AssistantMessage + parts: Array + } +} + +export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] + +export type SessionShellData = { + body?: { + agent: string + model?: { + providerID: string + modelID: string + } + command: string + } + path: { + /** + * Session ID + */ + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/shell" +} + +export type SessionShellErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionShellError = SessionShellErrors[keyof SessionShellErrors] + +export type SessionShellResponses = { + /** + * Created message + */ + 200: AssistantMessage +} + +export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] + +export type SessionRevertData = { + body?: { + messageID: string + partID?: string + } + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/revert" +} + +export type SessionRevertErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors] + +export type SessionRevertResponses = { + /** + * Updated session + */ + 200: Session +} + +export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] + +export type SessionUnrevertData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/unrevert" +} + +export type SessionUnrevertErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors] + +export type SessionUnrevertResponses = { + /** + * Updated session + */ + 200: Session +} + +export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] + +export type PostSessionIdPermissionsPermissionIdData = { + body?: { + response: "once" | "always" | "reject" + } + path: { + id: string + permissionID: string + } + query?: { + directory?: string + } + url: "/session/{id}/permissions/{permissionID}" +} + +export type PostSessionIdPermissionsPermissionIdErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PostSessionIdPermissionsPermissionIdError = + PostSessionIdPermissionsPermissionIdErrors[keyof PostSessionIdPermissionsPermissionIdErrors] + +export type PostSessionIdPermissionsPermissionIdResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PostSessionIdPermissionsPermissionIdResponse = + PostSessionIdPermissionsPermissionIdResponses[keyof PostSessionIdPermissionsPermissionIdResponses] + +export type CommandListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/command" +} + +export type CommandListResponses = { + /** + * List of commands + */ + 200: Array +} + +export type CommandListResponse = CommandListResponses[keyof CommandListResponses] + +export type ConfigProvidersData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/config/providers" +} + +export type ConfigProvidersResponses = { + /** + * List of providers + */ + 200: { + providers: Array + default: { + [key: string]: string + } + } +} + +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] + +export type ProviderListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/provider" +} + +export type ProviderListResponses = { + /** + * List of providers + */ + 200: { + all: Array<{ + api?: string + name: string + env: Array + id: string + npm?: string + models: { + [key: string]: { + id: string + name: string + release_date: string + attachment: boolean + reasoning: boolean + temperature: boolean + tool_call: boolean + cost?: { + input: number + output: number + cache_read?: number + cache_write?: number + context_over_200k?: { + input: number + output: number + cache_read?: number + cache_write?: number + } + } + limit: { + context: number + output: number + } + modalities?: { + input: Array<"text" | "audio" | "image" | "video" | "pdf"> + output: Array<"text" | "audio" | "image" | "video" | "pdf"> + } + experimental?: boolean + status?: "alpha" | "beta" | "deprecated" | "active" + options: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + provider?: { + npm: string + } + } + } + }> + default: { + [key: string]: string + } + connected: Array + } +} + +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] + +export type ProviderAuthData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/provider/auth" +} + +export type ProviderAuthResponses = { + /** + * Provider auth methods + */ + 200: { + [key: string]: Array + } +} + +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] + +export type ProviderOauthAuthorizeData = { + body?: { + /** + * Auth method index + */ + method: number + } + path: { + /** + * Provider ID + */ + id: string + } + query?: { + directory?: string + } + url: "/provider/{id}/oauth/authorize" +} + +export type ProviderOauthAuthorizeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] + +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: ProviderAuthAuthorization +} + +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] + +export type ProviderOauthCallbackData = { + body?: { + /** + * Auth method index + */ + method: number + /** + * OAuth authorization code + */ + code?: string + } + path: { + /** + * Provider ID + */ + id: string + } + query?: { + directory?: string + } + url: "/provider/{id}/oauth/callback" +} + +export type ProviderOauthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] + +export type ProviderOauthCallbackResponses = { + /** + * OAuth callback processed successfully + */ + 200: boolean +} + +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] + +export type FindTextData = { + body?: never + path?: never + query: { + directory?: string + pattern: string + } + url: "/find" +} + +export type FindTextResponses = { + /** + * Matches + */ + 200: Array<{ + path: { + text: string + } + lines: { + text: string + } + line_number: number + absolute_offset: number + submatches: Array<{ + match: { + text: string + } + start: number + end: number + }> + }> +} + +export type FindTextResponse = FindTextResponses[keyof FindTextResponses] + +export type FindFilesData = { + body?: never + path?: never + query: { + directory?: string + query: string + dirs?: "true" | "false" + } + url: "/find/file" +} + +export type FindFilesResponses = { + /** + * File paths + */ + 200: Array +} + +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] + +export type FindSymbolsData = { + body?: never + path?: never + query: { + directory?: string + query: string + } + url: "/find/symbol" +} + +export type FindSymbolsResponses = { + /** + * Symbols + */ + 200: Array +} + +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] + +export type FileListData = { + body?: never + path?: never + query: { + directory?: string + path: string + } + url: "/file" +} + +export type FileListResponses = { + /** + * Files and directories + */ + 200: Array +} + +export type FileListResponse = FileListResponses[keyof FileListResponses] + +export type FileReadData = { + body?: never + path?: never + query: { + directory?: string + path: string + } + url: "/file/content" +} + +export type FileReadResponses = { + /** + * File content + */ + 200: FileContent +} + +export type FileReadResponse = FileReadResponses[keyof FileReadResponses] + +export type FileStatusData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/file/status" +} + +export type FileStatusResponses = { + /** + * File status + */ + 200: Array +} + +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] + +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn" + /** + * Log message + */ + message: string + /** + * Additional metadata for the log entry + */ + extra?: { + [key: string]: unknown + } + } + path?: never + query?: { + directory?: string + } + url: "/log" +} + +export type AppLogErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppLogError = AppLogErrors[keyof AppLogErrors] + +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean +} + +export type AppLogResponse = AppLogResponses[keyof AppLogResponses] + +export type AppAgentsData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/agent" +} + +export type AppAgentsResponses = { + /** + * List of agents + */ + 200: Array +} + +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] + +export type McpStatusData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/mcp" +} + +export type McpStatusResponses = { + /** + * MCP server status + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] + +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + } + url: "/mcp" +} + +export type McpAddErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpAddError = McpAddErrors[keyof McpAddErrors] + +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] + +export type McpAuthRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] + +export type McpAuthRemoveResponses = { + /** + * OAuth credentials removed + */ + 200: { + success: true + } +} + +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] + +export type McpAuthStartData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthStartErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] + +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + /** + * URL to open in browser for authorization + */ + authorizationUrl: string + } +} + +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] + +export type McpAuthCallbackData = { + body?: { + /** + * Authorization code from OAuth callback + */ + code: string + } + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/auth/callback" +} + +export type McpAuthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] + +export type McpAuthCallbackResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] + +export type McpAuthAuthenticateData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/auth/authenticate" +} + +export type McpAuthAuthenticateErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] + +export type McpAuthAuthenticateResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] + +export type McpConnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/connect" +} + +export type McpConnectResponses = { + /** + * MCP server connected successfully + */ + 200: boolean +} + +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] + +export type McpDisconnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/disconnect" +} + +export type McpDisconnectResponses = { + /** + * MCP server disconnected successfully + */ + 200: boolean +} + +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] + +export type LspStatusData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/lsp" +} + +export type LspStatusResponses = { + /** + * LSP server status + */ + 200: Array +} + +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] + +export type FormatterStatusData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/formatter" +} + +export type FormatterStatusResponses = { + /** + * Formatter status + */ + 200: Array +} + +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] + +export type TuiAppendPromptData = { + body?: { + text: string + } + path?: never + query?: { + directory?: string + } + url: "/tui/append-prompt" +} + +export type TuiAppendPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiAppendPromptError = TuiAppendPromptErrors[keyof TuiAppendPromptErrors] + +export type TuiAppendPromptResponses = { + /** + * Prompt processed successfully + */ + 200: boolean +} + +export type TuiAppendPromptResponse = TuiAppendPromptResponses[keyof TuiAppendPromptResponses] + +export type TuiOpenHelpData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/open-help" +} + +export type TuiOpenHelpResponses = { + /** + * Help dialog opened successfully + */ + 200: boolean +} + +export type TuiOpenHelpResponse = TuiOpenHelpResponses[keyof TuiOpenHelpResponses] + +export type TuiOpenSessionsData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/open-sessions" +} + +export type TuiOpenSessionsResponses = { + /** + * Session dialog opened successfully + */ + 200: boolean +} + +export type TuiOpenSessionsResponse = TuiOpenSessionsResponses[keyof TuiOpenSessionsResponses] + +export type TuiOpenThemesData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/open-themes" +} + +export type TuiOpenThemesResponses = { + /** + * Theme dialog opened successfully + */ + 200: boolean +} + +export type TuiOpenThemesResponse = TuiOpenThemesResponses[keyof TuiOpenThemesResponses] + +export type TuiOpenModelsData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/open-models" +} + +export type TuiOpenModelsResponses = { + /** + * Model dialog opened successfully + */ + 200: boolean +} + +export type TuiOpenModelsResponse = TuiOpenModelsResponses[keyof TuiOpenModelsResponses] + +export type TuiSubmitPromptData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/submit-prompt" +} + +export type TuiSubmitPromptResponses = { + /** + * Prompt submitted successfully + */ + 200: boolean +} + +export type TuiSubmitPromptResponse = TuiSubmitPromptResponses[keyof TuiSubmitPromptResponses] + +export type TuiClearPromptData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/clear-prompt" +} + +export type TuiClearPromptResponses = { + /** + * Prompt cleared successfully + */ + 200: boolean +} + +export type TuiClearPromptResponse = TuiClearPromptResponses[keyof TuiClearPromptResponses] + +export type TuiExecuteCommandData = { + body?: { + command: string + } + path?: never + query?: { + directory?: string + } + url: "/tui/execute-command" +} + +export type TuiExecuteCommandErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiExecuteCommandError = TuiExecuteCommandErrors[keyof TuiExecuteCommandErrors] + +export type TuiExecuteCommandResponses = { + /** + * Command executed successfully + */ + 200: boolean +} + +export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses] + +export type TuiShowToastData = { + body?: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } + path?: never + query?: { + directory?: string + } + url: "/tui/show-toast" +} + +export type TuiShowToastResponses = { + /** + * Toast notification shown successfully + */ + 200: boolean +} + +export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] + +export type TuiPublishData = { + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow + path?: never + query?: { + directory?: string + } + url: "/tui/publish" +} + +export type TuiPublishErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors] + +export type TuiPublishResponses = { + /** + * Event published successfully + */ + 200: boolean +} + +export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses] + +export type TuiControlNextData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/control/next" +} + +export type TuiControlNextResponses = { + /** + * Next TUI request + */ + 200: { + path: string + body: unknown + } +} + +export type TuiControlNextResponse = TuiControlNextResponses[keyof TuiControlNextResponses] + +export type TuiControlResponseData = { + body?: unknown + path?: never + query?: { + directory?: string + } + url: "/tui/control/response" +} + +export type TuiControlResponseResponses = { + /** + * Response submitted successfully + */ + 200: boolean +} + +export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] + +export type AuthSetData = { + body?: Auth + path: { + id: string + } + query?: { + directory?: string + } + url: "/auth/{id}" +} + +export type AuthSetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] + +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean +} + +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] + +export type EventSubscribeData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/event" +} + +export type EventSubscribeResponses = { + /** + * Event stream + */ + 200: Event +} + +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}) +} diff --git a/packages/sdk/js/src/index.ts b/packages/sdk/js/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d044f5ad66e49daa401a74a44a19fc46dbd9d2c3 --- /dev/null +++ b/packages/sdk/js/src/index.ts @@ -0,0 +1,21 @@ +export * from "./client.js" +export * from "./server.js" + +import { createOpencodeClient } from "./client.js" +import { createOpencodeServer } from "./server.js" +import type { ServerOptions } from "./server.js" + +export async function createOpencode(options?: ServerOptions) { + const server = await createOpencodeServer({ + ...options, + }) + + const client = createOpencodeClient({ + baseUrl: server.url, + }) + + return { + client, + server, + } +} diff --git a/packages/sdk/js/src/process.ts b/packages/sdk/js/src/process.ts new file mode 100644 index 0000000000000000000000000000000000000000..3111b424aa18e41623b6b13e9f9b3e1dbe7b265c --- /dev/null +++ b/packages/sdk/js/src/process.ts @@ -0,0 +1,31 @@ +import { type ChildProcess, spawnSync } from "node:child_process" + +// Duplicated from `packages/opencode/src/util/process.ts` because the SDK cannot +// import `opencode` without creating a cycle (`opencode` depends on `@opencode-ai/sdk`). +export function stop(proc: ChildProcess) { + if (proc.exitCode !== null || proc.signalCode !== null) return + if (process.platform === "win32" && proc.pid) { + const out = spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true }) + if (!out.error && out.status === 0) return + } + proc.kill() +} + +export function bindAbort(proc: ChildProcess, signal?: AbortSignal, onAbort?: () => void) { + if (!signal) return () => {} + const abort = () => { + clear() + stop(proc) + onAbort?.() + } + const clear = () => { + signal.removeEventListener("abort", abort) + proc.off("exit", clear) + proc.off("error", clear) + } + signal.addEventListener("abort", abort, { once: true }) + proc.on("exit", clear) + proc.on("error", clear) + if (signal.aborted) abort() + return clear +} diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d1ab29fc92880f2f5a35375f26012b3725a8dcd --- /dev/null +++ b/packages/sdk/js/src/server.ts @@ -0,0 +1,134 @@ +import launch from "cross-spawn" +import { type Config } from "./gen/types.gen.js" +import { stop, bindAbort } from "./process.js" + +export type ServerOptions = { + hostname?: string + port?: number + signal?: AbortSignal + timeout?: number + config?: Config +} + +export type TuiOptions = { + project?: string + model?: string + session?: string + agent?: string + signal?: AbortSignal + config?: Config +} + +export async function createOpencodeServer(options?: ServerOptions) { + options = Object.assign( + { + hostname: "127.0.0.1", + port: 4096, + timeout: 5000, + }, + options ?? {}, + ) + + const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] + if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) + + const proc = launch(`opencode`, args, { + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), + }, + }) + let clear = () => {} + + const url = await new Promise((resolve, reject) => { + const id = setTimeout(() => { + clear() + stop(proc) + reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`)) + }, options.timeout) + let output = "" + let resolved = false + proc.stdout?.on("data", (chunk) => { + if (resolved) return + output += chunk.toString() + const lines = output.split("\n") + for (const line of lines) { + if (line.startsWith("opencode server listening")) { + const match = line.match(/on\s+(https?:\/\/[^\s]+)/) + if (!match) { + clear() + stop(proc) + clearTimeout(id) + reject(new Error(`Failed to parse server url from output: ${line}`)) + return + } + clearTimeout(id) + resolved = true + resolve(match[1]!) + return + } + } + }) + proc.stderr?.on("data", (chunk) => { + output += chunk.toString() + }) + proc.on("exit", (code) => { + clearTimeout(id) + let msg = `Server exited with code ${code}` + if (output.trim()) { + msg += `\nServer output: ${output}` + } + reject(new Error(msg)) + }) + proc.on("error", (error) => { + clearTimeout(id) + reject(error) + }) + clear = bindAbort(proc, options.signal, () => { + clearTimeout(id) + reject(options.signal?.reason) + }) + }) + + return { + url, + close() { + clear() + stop(proc) + }, + } +} + +export function createOpencodeTui(options?: TuiOptions) { + const args = [] + + if (options?.project) { + args.push(`--project=${options.project}`) + } + if (options?.model) { + args.push(`--model=${options.model}`) + } + if (options?.session) { + args.push(`--session=${options.session}`) + } + if (options?.agent) { + args.push(`--agent=${options.agent}`) + } + + const proc = launch(`opencode`, args, { + stdio: "inherit", + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}), + }, + }) + + const clear = bindAbort(proc, options?.signal) + + return { + close() { + clear() + stop(proc) + }, + } +} diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1956cffe037c17b8d29360b3f348a8f69c1f699 --- /dev/null +++ b/packages/sdk/js/src/v2/client.ts @@ -0,0 +1,93 @@ +export * from "./gen/types.gen.js" +export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js" + +import { createClient } from "./gen/client/client.gen.js" +import { type Config } from "./gen/client/types.gen.js" +import { OpencodeClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "../error-interceptor.js" +export { type Config as OpencodeClientConfig, OpencodeClient } + +function pick(value: string | null, fallback?: string, encode?: (value: string) => string) { + if (!value) return + if (!fallback) return value + if (value === fallback) return fallback + if (encode && value === encode(fallback)) return fallback + return value +} + +function rewrite(request: Request, values: { directory?: string; workspace?: string }) { + if (request.method !== "GET" && request.method !== "HEAD") return request + + const url = new URL(request.url) + let changed = false + + for (const [name, key] of [ + ["x-opencode-directory", "directory"], + ["x-opencode-workspace", "workspace"], + ] as const) { + const value = pick( + request.headers.get(name), + key === "directory" ? values.directory : values.workspace, + key === "directory" ? encodeURIComponent : undefined, + ) + if (!value) continue + for (const query of url.pathname.startsWith("/api/") ? [key, `location[${key}]`] : [key]) { + if (!url.searchParams.has(query)) { + url.searchParams.set(query, value) + } + } + changed = true + } + + if (!changed) return request + + const next = new Request(url, request) + next.headers.delete("x-opencode-directory") + next.headers.delete("x-opencode-workspace") + return next +} + +export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) { + if (!config?.fetch) { + const customFetch: any = (req: any) => { + // @ts-ignore + req.timeout = false + return fetch(req) + } + config = { + ...config, + fetch: customFetch, + } + } + + if (config?.directory) { + config.headers = { + ...config.headers, + "x-opencode-directory": encodeURIComponent(config.directory), + } + } + + if (config?.experimental_workspaceID) { + config.headers = { + ...config.headers, + "x-opencode-workspace": config.experimental_workspaceID, + } + } + + const client = createClient(config) + client.interceptors.request.use((request) => + rewrite(request, { + directory: config?.directory, + workspace: config?.experimental_workspaceID, + }), + ) + client.interceptors.response.use((response) => { + const contentType = response.headers.get("content-type") + if (contentType === "text/html") + throw new Error("Request is not supported by this version of OpenCode Server (Server responded with text/html)") + + return response + }) + client.interceptors.error.use(wrapClientError) + return new OpencodeClient({ client }) +} diff --git a/packages/sdk/js/src/v2/data.ts b/packages/sdk/js/src/v2/data.ts new file mode 100644 index 0000000000000000000000000000000000000000..776b168ad9d3a88d558035cd878827cae320ab09 --- /dev/null +++ b/packages/sdk/js/src/v2/data.ts @@ -0,0 +1,32 @@ +import type { Part, UserMessage } from "./client.js" + +export const message = { + user(input: Omit & { parts: Omit[] }): { + info: UserMessage + parts: Part[] + } { + const { parts: _parts, ...rest } = input + + const info: UserMessage = { + ...rest, + id: "asdasd", + time: { + created: Date.now(), + }, + role: "user", + } + + return { + info, + parts: input.parts.map( + (part) => + ({ + ...part, + id: "asdasd", + messageID: info.id, + sessionID: info.sessionID, + }) as Part, + ), + } + }, +} diff --git a/packages/sdk/js/src/v2/gen/client.gen.ts b/packages/sdk/js/src/v2/gen/client.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c110eca39ba4ef5ef0957b96a83b35abfd2a01a --- /dev/null +++ b/packages/sdk/js/src/v2/gen/client.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from "./client/index.js" +import type { ClientOptions as ClientOptions2 } from "./types.gen.js" + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T> + +export const client = createClient(createConfig({ baseUrl: "http://localhost:4096" })) diff --git a/packages/sdk/js/src/v2/gen/client/client.gen.ts b/packages/sdk/js/src/v2/gen/client/client.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..627e98ec4206c45b5a78fa9a8373daa680316535 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/client/client.gen.ts @@ -0,0 +1,285 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from "../core/serverSentEvents.gen.js" +import type { HttpMethod } from "../core/types.gen.js" +import { getValidRequestBody } from "../core/utils.gen.js" +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js" +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from "./utils.gen.js" + +type ReqInit = Omit & { + body?: any + headers: ReturnType +} + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config) + + const getConfig = (): Config => ({ ..._config }) + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config) + return getConfig() + } + + const interceptors = createInterceptors() + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + } + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }) + } + + if (opts.requestValidator) { + await opts.requestValidator(opts) + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === "") { + opts.headers.delete("Content-Type") + } + + const url = buildUrl(opts) + + return { opts, url } + } + + const request: Client["request"] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options) + const requestInit: ReqInit = { + redirect: "follow", + ...opts, + body: getValidRequestBody(opts), + } + + let request = new Request(url, requestInit) + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts) + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch! + let response: Response + + try { + response = await _fetch(request) + } catch (error) { + // Handle fetch exceptions (AbortError, network errors, etc.) + let finalError = error + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, undefined as any, request, opts)) as unknown + } + } + + finalError = finalError || ({} as unknown) + + if (opts.throwOnError) { + throw finalError + } + + // Return error response + return opts.responseStyle === "data" + ? undefined + : { + error: finalError, + request, + response: undefined as any, + } + } + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts) + } + } + + const result = { + request, + response, + } + + if (response.ok) { + const parseAs = + (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json" + + if (response.status === 204 || response.headers.get("Content-Length") === "0") { + let emptyData: any + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "text": + emptyData = await response[parseAs]() + break + case "formData": + emptyData = new FormData() + break + case "stream": + emptyData = response.body + break + case "json": + default: + emptyData = {} + break + } + return opts.responseStyle === "data" + ? emptyData + : { + data: emptyData, + ...result, + } + } + + let data: any + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "formData": + case "text": + data = await response[parseAs]() + break + case "json": { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text() + data = text ? JSON.parse(text) : {} + break + } + case "stream": + return opts.responseStyle === "data" + ? response.body + : { + data: response.body, + ...result, + } + } + + if (parseAs === "json") { + if (opts.responseValidator) { + await opts.responseValidator(data) + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data) + } + } + + return opts.responseStyle === "data" + ? data + : { + data, + ...result, + } + } + + const textError = await response.text() + let jsonError: unknown + + try { + jsonError = JSON.parse(textError) + } catch { + // noop + } + + const error = jsonError ?? textError + let finalError = error + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string + } + } + + finalError = finalError || ({} as string) + + if (opts.throwOnError) { + throw finalError + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === "data" + ? undefined + : { + error: finalError, + ...result, + } + } + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => request({ ...options, method }) + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options) + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + onRequest: async (url, init) => { + let request = new Request(url, init) + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts) + } + } + return request + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }) + } + + return { + buildUrl, + connect: makeMethodFn("CONNECT"), + delete: makeMethodFn("DELETE"), + get: makeMethodFn("GET"), + getConfig, + head: makeMethodFn("HEAD"), + interceptors, + options: makeMethodFn("OPTIONS"), + patch: makeMethodFn("PATCH"), + post: makeMethodFn("POST"), + put: makeMethodFn("PUT"), + request, + setConfig, + sse: { + connect: makeSseFn("CONNECT"), + delete: makeSseFn("DELETE"), + get: makeSseFn("GET"), + head: makeSseFn("HEAD"), + options: makeSseFn("OPTIONS"), + patch: makeSseFn("PATCH"), + post: makeSseFn("POST"), + put: makeSseFn("PUT"), + trace: makeSseFn("TRACE"), + }, + trace: makeMethodFn("TRACE"), + } as Client +} diff --git a/packages/sdk/js/src/v2/gen/client/index.ts b/packages/sdk/js/src/v2/gen/client/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0af63f3300ebf4a3fb65d563c0c3cbec9c078040 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from "../core/auth.gen.js" +export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from "../core/bodySerializer.gen.js" +export { buildClientParams } from "../core/params.gen.js" +export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen.js" +export { createClient } from "./client.gen.js" +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from "./types.gen.js" +export { createConfig, mergeHeaders } from "./utils.gen.js" diff --git a/packages/sdk/js/src/v2/gen/client/types.gen.ts b/packages/sdk/js/src/v2/gen/client/types.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..99d7e7f8f2e8dd0034a98100114119e7d149dfa3 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/client/types.gen.ts @@ -0,0 +1,202 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from "../core/auth.gen.js" +import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen.js" +import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js" +import type { Middleware } from "./utils.gen.js" + +export type ResponseStyle = "data" | "fields" + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T["baseUrl"] + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text" + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T["throwOnError"] +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle + throwOnError: ThrowOnError + }>, + Pick< + ServerSentEventsOptions, + "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay" + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown + path?: Record + query?: Record + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray + url: Url +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends "data" + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData + request: Request + response: Response + } + > + : Promise< + TResponseStyle extends "data" + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData + error: undefined + } + | { + data: undefined + error: TError extends Record ? TError[keyof TError] : TError + } + ) & { + request: Request + response: Response + } + > + +export interface ClientOptions { + baseUrl?: string + responseStyle?: ResponseStyle + throwOnError?: boolean +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => RequestResult + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => Promise> + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method"> & + Pick>, "method">, +) => RequestResult + +type BuildUrlFn = < + TData extends { + body?: unknown + path?: Record + query?: Record + url: string + }, +>( + options: TData & Options, +) => string + +export type Client = CoreClient & { + interceptors: Middleware +} + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T> + +export interface TDataShape { + body?: unknown + headers?: unknown + path?: unknown + query?: unknown + url: string +} + +type OmitKeys = Pick> + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = "fields", +> = OmitKeys, "body" | "path" | "query" | "url"> & + ([TData] extends [never] ? unknown : Omit) diff --git a/packages/sdk/js/src/v2/gen/client/utils.gen.ts b/packages/sdk/js/src/v2/gen/client/utils.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b1dfb78718807db596609d0d2d765f02d9c00bb --- /dev/null +++ b/packages/sdk/js/src/v2/gen/client/utils.gen.ts @@ -0,0 +1,289 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from "../core/auth.gen.js" +import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" +import { jsonBodySerializer } from "../core/bodySerializer.gen.js" +import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen.js" +import { getUrl } from "../core/utils.gen.js" +import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js" + +export const createQuerySerializer = ({ parameters = {}, ...args }: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = [] + if (queryParams && typeof queryParams === "object") { + for (const name in queryParams) { + const value = queryParams[name] + + if (value === undefined || value === null) { + continue + } + + const options = parameters[name] || args + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: "form", + value, + ...options.array, + }) + if (serializedArray) search.push(serializedArray) + } else if (typeof value === "object") { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: "deepObject", + value: value as Record, + ...options.object, + }) + if (serializedObject) search.push(serializedObject) + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }) + if (serializedPrimitive) search.push(serializedPrimitive) + } + } + } + return search.join("&") + } + return querySerializer +} + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return "stream" + } + + const cleanContent = contentType.split(";")[0]?.trim() + + if (!cleanContent) { + return + } + + if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) { + return "json" + } + + if (cleanContent === "multipart/form-data") { + return "formData" + } + + if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) { + return "blob" + } + + if (cleanContent.startsWith("text/")) { + return "text" + } + + return +} + +const checkForExistence = ( + options: Pick & { + headers: Headers + }, + name?: string, +): boolean => { + if (!name) { + return false + } + if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) { + return true + } + return false +} + +export const setAuthParams = async ({ + security, + ...options +}: Pick, "security"> & + Pick & { + headers: Headers + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue + } + + const token = await getAuthToken(auth, options.auth) + + if (!token) { + continue + } + + const name = auth.name ?? "Authorization" + + switch (auth.in) { + case "query": + if (!options.query) { + options.query = {} + } + options.query[name] = token + break + case "cookie": + options.headers.append("Cookie", `${name}=${token}`) + break + case "header": + default: + options.headers.set(name, token) + break + } + } +} + +export const buildUrl: Client["buildUrl"] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === "function" + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }) + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b } + if (config.baseUrl?.endsWith("/")) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1) + } + config.headers = mergeHeaders(a.headers, b.headers) + return config +} + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = [] + headers.forEach((value, key) => { + entries.push([key, value]) + }) + return entries +} + +export const mergeHeaders = (...headers: Array["headers"] | undefined>): Headers => { + const mergedHeaders = new Headers() + for (const header of headers) { + if (!header) { + continue + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header) + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key) + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string) + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string)) + } + } + } + return mergedHeaders +} + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise + +type ResInterceptor = (response: Res, request: Req, options: Options) => Res | Promise + +class Interceptors { + fns: Array = [] + + clear(): void { + this.fns = [] + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id) + if (this.fns[index]) { + this.fns[index] = null + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id) + return Boolean(this.fns[index]) + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === "number") { + return this.fns[id] ? id : -1 + } + return this.fns.indexOf(id) + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id) + if (this.fns[index]) { + this.fns[index] = fn + return id + } + return false + } + + use(fn: Interceptor): number { + this.fns.push(fn) + return this.fns.length - 1 + } +} + +export interface Middleware { + error: Interceptors> + request: Interceptors> + response: Interceptors> +} + +export const createInterceptors = (): Middleware => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}) + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: "form", + }, + object: { + explode: true, + style: "deepObject", + }, +}) + +const defaultHeaders = { + "Content-Type": "application/json", +} + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: "auto", + querySerializer: defaultQuerySerializer, + ...override, +}) diff --git a/packages/sdk/js/src/v2/gen/core/auth.gen.ts b/packages/sdk/js/src/v2/gen/core/auth.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc7b230f4475a6a52271a6e6ac77437c244cea32 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/core/auth.gen.ts @@ -0,0 +1,41 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: "header" | "query" | "cookie" + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string + scheme?: "basic" | "bearer" + type: "apiKey" | "http" +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === "function" ? await callback(auth) : callback + + if (!token) { + return + } + + if (auth.scheme === "bearer") { + return `Bearer ${token}` + } + + if (auth.scheme === "basic") { + return `Basic ${btoa(token)}` + } + + return token +} diff --git a/packages/sdk/js/src/v2/gen/core/bodySerializer.gen.ts b/packages/sdk/js/src/v2/gen/core/bodySerializer.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..9678fb08ec6fbc5b7bd946cad03f506c6cb85457 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js" + +export type QuerySerializer = (query: Record) => string + +export type BodySerializer = (body: any) => any + +type QuerySerializerOptionsObject = { + allowReserved?: boolean + array?: Partial> + object?: Partial> +} + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record +} + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === "string" || value instanceof Blob) { + data.append(key, value) + } else if (value instanceof Date) { + data.append(key, value.toISOString()) + } else { + data.append(key, JSON.stringify(value)) + } +} + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === "string") { + data.append(key, value) + } else { + data.append(key, JSON.stringify(value)) + } +} + +export const formDataBodySerializer = { + bodySerializer: | Array>>(body: T): FormData => { + const data = new FormData() + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)) + } else { + serializeFormDataPair(data, key, value) + } + }) + + return data + }, +} + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), +} + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>(body: T): string => { + const data = new URLSearchParams() + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) + } else { + serializeUrlSearchParamsPair(data, key, value) + } + }) + + return data.toString() + }, +} diff --git a/packages/sdk/js/src/v2/gen/core/params.gen.ts b/packages/sdk/js/src/v2/gen/core/params.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e9d0b9add42f8929c25ea8f93de16792cc702d1 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/core/params.gen.ts @@ -0,0 +1,169 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = "body" | "headers" | "path" | "query" + +export type Field = + | { + in: Exclude + /** + * Field name. This is the name we want the user to see and use. + */ + key: string + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string + } + | { + in: Extract + /** + * Key isn't required for bodies. + */ + key?: string + map?: string + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot + } + +export interface Fields { + allowExtra?: Partial> + args?: ReadonlyArray +} + +export type FieldsConfig = ReadonlyArray + +const extraPrefixesMap: Record = { + $body_: "body", + $headers_: "headers", + $path_: "path", + $query_: "query", +} +const extraPrefixes = Object.entries(extraPrefixesMap) + +type KeyMap = Map< + string, + | { + in: Slot + map?: string + } + | { + in?: never + map: Slot + } +> + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map() + } + + for (const config of fields) { + if ("in" in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }) + } + } else if ("key" in config) { + map.set(config.key, { + map: config.map, + }) + } else if (config.args) { + buildKeyMap(config.args, map) + } + } + + return map +} + +interface Params { + body: unknown + headers: Record + path: Record + query: Record +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === "object" && !Object.keys(value).length) { + delete params[slot as Slot] + } + } +} + +export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + } + + const map = buildKeyMap(fields) + + let config: FieldsConfig[number] | undefined + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index] + } + + if (!config) { + continue + } + + if ("in" in config) { + if (config.key) { + const field = map.get(config.key)! + const name = field.map || config.key + if (field.in) { + ;(params[field.in] as Record)[name] = arg + } + } else { + params.body = arg + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key) + + if (field) { + if (field.in) { + const name = field.map || key + ;(params[field.in] as Record)[name] = value + } else { + params[field.map] = value + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) + + if (extra) { + const [prefix, slot] = extra + ;(params[slot] as Record)[key.slice(prefix.length)] = value + } else if ("allowExtra" in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + ;(params[slot as Slot] as Record)[key] = value + break + } + } + } + } + } + } + } + + stripEmptySlots(params) + + return params +} diff --git a/packages/sdk/js/src/v2/gen/core/pathSerializer.gen.ts b/packages/sdk/js/src/v2/gen/core/pathSerializer.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..96be3bc5a3979b818ee3bd471a84ccd8d454ecd6 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/core/pathSerializer.gen.ts @@ -0,0 +1,167 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean + name: string +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean + style: T +} + +export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited" +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle +type MatrixStyle = "label" | "matrix" | "simple" +export type ObjectStyle = "form" | "deepObject" +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "label": + return "." + case "matrix": + return ";" + case "simple": + return "," + default: + return "&" + } +} + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "form": + return "," + case "pipeDelimited": + return "|" + case "spaceDelimited": + return "%20" + default: + return "," + } +} + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case "label": + return "." + case "matrix": + return ";" + case "simple": + return "," + default: + return "&" + } +} + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[] +}) => { + if (!explode) { + const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( + separatorArrayNoExplode(style), + ) + switch (style) { + case "label": + return `.${joinedValues}` + case "matrix": + return `;${name}=${joinedValues}` + case "simple": + return joinedValues + default: + return `${name}=${joinedValues}` + } + } + + const separator = separatorArrayExplode(style) + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string) + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }) + }) + .join(separator) + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues +} + +export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return "" + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ) + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}` +} + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date + valueOnly?: boolean +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` + } + + if (style !== "deepObject" && !explode) { + let values: string[] = [] + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)] + }) + const joinedValues = values.join(",") + switch (style) { + case "form": + return `${name}=${joinedValues}` + case "label": + return `.${joinedValues}` + case "matrix": + return `;${name}=${joinedValues}` + default: + return joinedValues + } + } + + const separator = separatorObjectExplode(style) + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator) + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues +} diff --git a/packages/sdk/js/src/v2/gen/core/queryKeySerializer.gen.ts b/packages/sdk/js/src/v2/gen/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..320204aef108409af21c70cc51c9d2e6a606fe63 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/core/queryKeySerializer.gen.ts @@ -0,0 +1,111 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue } + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined + } + if (typeof value === "bigint") { + return value.toString() + } + if (value instanceof Date) { + return value.toISOString() + } + return value +} + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer) + if (json === undefined) { + return undefined + } + return JSON.parse(json) as JsonValue + } catch { + return undefined + } +} + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false + } + const prototype = Object.getPrototypeOf(value as object) + return prototype === Object.prototype || prototype === null +} + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)) + const result: Record = {} + + for (const [key, value] of entries) { + const existing = result[key] + if (existing === undefined) { + result[key] = value + continue + } + + if (Array.isArray(existing)) { + ;(existing as string[]).push(value) + } else { + result[key] = [existing, value] + } + } + + return result +} + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value + } + + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined + } + + if (typeof value === "bigint") { + return value.toString() + } + + if (value instanceof Date) { + return value.toISOString() + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value) + } + + if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) { + return serializeSearchParams(value) + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value) + } + + return undefined +} diff --git a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..056a812593223d1fed57b7b0bbaac58f4ab37c23 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts @@ -0,0 +1,239 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from "./types.gen.js" + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void + serializedBody?: RequestInit["body"] + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise + url: string + } + +export interface StreamEvent { + data: TData + event?: string + id?: string + retry?: number +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator ? TData[keyof TData] : TData, TReturn, TNext> +} + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000 + let attempt = 0 + const signal = options.signal ?? new AbortController().signal + + while (true) { + if (signal.aborted) break + + attempt++ + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined) + + if (lastEventId !== undefined) { + headers.set("Last-Event-ID", lastEventId) + } + + try { + const requestInit: RequestInit = { + redirect: "follow", + ...options, + body: options.serializedBody, + headers, + signal, + } + let request = new Request(url, requestInit) + if (onRequest) { + request = await onRequest(url, requestInit) + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch + const response = await _fetch(request) + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`) + + if (!response.body) throw new Error("No body in SSE response") + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + + let buffer = "" + + const abortHandler = () => { + try { + reader.cancel() + } catch { + // noop + } + } + + signal.addEventListener("abort", abortHandler) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += value + // Normalize line endings: CRLF -> LF, then CR -> LF + buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + + const chunks = buffer.split("\n\n") + buffer = chunks.pop() ?? "" + + for (const chunk of chunks) { + const lines = chunk.split("\n") + const dataLines: Array = [] + let eventName: string | undefined + + for (const line of lines) { + if (line.startsWith("data:")) { + dataLines.push(line.replace(/^data:\s*/, "")) + } else if (line.startsWith("event:")) { + eventName = line.replace(/^event:\s*/, "") + } else if (line.startsWith("id:")) { + lastEventId = line.replace(/^id:\s*/, "") + } else if (line.startsWith("retry:")) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) + if (!Number.isNaN(parsed)) { + retryDelay = parsed + } + } + } + + let data: unknown + let parsedJson = false + + if (dataLines.length) { + const rawData = dataLines.join("\n") + try { + data = JSON.parse(rawData) + parsedJson = true + } catch { + data = rawData + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data) + } + + if (responseTransformer) { + data = await responseTransformer(data) + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }) + + if (dataLines.length) { + yield data as any + } + } + } + } finally { + signal.removeEventListener("abort", abortHandler) + reader.releaseLock() + } + + break // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error) + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000) + await sleep(backoff) + } + } + } + + const stream = createStream() + + return { stream } +} diff --git a/packages/sdk/js/src/v2/gen/core/types.gen.ts b/packages/sdk/js/src/v2/gen/core/types.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfa77b8acd2ba47062042b8d5e6b17b8eb947bb5 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/core/types.gen.ts @@ -0,0 +1,86 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from "./auth.gen.js" +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js" + +export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace" + +export type Client = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn + getConfig: () => Config + request: RequestFn + setConfig: (config: Config) => Config +} & { + [K in HttpMethod]: MethodFn +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }) + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit["headers"] + | Record + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K] +} diff --git a/packages/sdk/js/src/v2/gen/core/utils.gen.ts b/packages/sdk/js/src/v2/gen/core/utils.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a45f72698aed71503aef0bb9f052c1707381b7a --- /dev/null +++ b/packages/sdk/js/src/v2/gen/core/utils.gen.ts @@ -0,0 +1,137 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js" +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "./pathSerializer.gen.js" + +export interface PathSerializer { + path: Record + url: string +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url + const matches = _url.match(PATH_PARAM_RE) + if (matches) { + for (const match of matches) { + let explode = false + let name = match.substring(1, match.length - 1) + let style: ArraySeparatorStyle = "simple" + + if (name.endsWith("*")) { + explode = true + name = name.substring(0, name.length - 1) + } + + if (name.startsWith(".")) { + name = name.substring(1) + style = "label" + } else if (name.startsWith(";")) { + name = name.substring(1) + style = "matrix" + } + + const value = path[name] + + if (value === undefined || value === null) { + continue + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })) + continue + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ) + continue + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ) + continue + } + + const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string)) + url = url.replace(match, replaceValue) + } + } + return url +} + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string + path?: Record + query?: Record + querySerializer: QuerySerializer + url: string +}) => { + const pathUrl = _url.startsWith("/") ? _url : `/${_url}` + let url = (baseUrl ?? "") + pathUrl + if (path) { + url = defaultPathSerializer({ path, url }) + } + let search = query ? querySerializer(query) : "" + if (search.startsWith("?")) { + search = search.substring(1) + } + if (search) { + url += `?${search}` + } + return url +} + +export function getValidRequestBody(options: { + body?: unknown + bodySerializer?: BodySerializer | null + serializedBody?: unknown +}) { + const hasBody = options.body !== undefined + const isSerializedBody = hasBody && options.bodySerializer + + if (isSerializedBody) { + if ("serializedBody" in options) { + const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "" + + return hasSerializedBody ? options.serializedBody : null + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== "" ? options.body : null + } + + // plain/text body + if (hasBody) { + return options.body + } + + // no body was provided + return undefined +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2bcd4252c6d8a185469dcda23a8cccc819218f0 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -0,0 +1,7219 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { client } from "./client.gen.js" +import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" +import type { + AgentPartInput, + AppAgentsErrors, + AppAgentsResponses, + AppLogErrors, + AppLogResponses, + AppSkillsErrors, + AppSkillsResponses, + Auth as Auth3, + AuthRemoveErrors, + AuthRemoveResponses, + AuthSetErrors, + AuthSetResponses, + CommandListErrors, + CommandListResponses, + Config as Config3, + ConfigGetErrors, + ConfigGetResponses, + ConfigProvidersErrors, + ConfigProvidersResponses, + ConfigUpdateErrors, + ConfigUpdateResponses, + EventSubscribeResponses, + EventTuiCommandExecute, + EventTuiPromptAppend, + EventTuiSessionSelect, + EventTuiToastShow, + ExperimentalCapabilitiesGetErrors, + ExperimentalCapabilitiesGetResponses, + ExperimentalConsoleGetErrors, + ExperimentalConsoleGetResponses, + ExperimentalConsoleListOrgsErrors, + ExperimentalConsoleListOrgsResponses, + ExperimentalConsoleSwitchOrgResponses, + ExperimentalControlPlaneMoveSessionErrors, + ExperimentalControlPlaneMoveSessionResponses, + ExperimentalProjectCopyGenerateNameErrors, + ExperimentalProjectCopyGenerateNameResponses, + ExperimentalResourceListErrors, + ExperimentalResourceListResponses, + ExperimentalSessionBackgroundErrors, + ExperimentalSessionBackgroundResponses, + ExperimentalSessionListErrors, + ExperimentalSessionListResponses, + ExperimentalWorkspaceAdapterListErrors, + ExperimentalWorkspaceAdapterListResponses, + ExperimentalWorkspaceCreateErrors, + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceListErrors, + ExperimentalWorkspaceListResponses, + ExperimentalWorkspaceRemoveErrors, + ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceStatusErrors, + ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceSyncListErrors, + ExperimentalWorkspaceSyncListResponses, + ExperimentalWorkspaceWarpErrors, + ExperimentalWorkspaceWarpResponses, + FileListErrors, + FileListResponses, + FilePartInput, + FilePartSource, + FileReadErrors, + FileReadResponses, + FileStatusErrors, + FileStatusResponses, + FindFilesErrors, + FindFilesResponses, + FindSymbolsErrors, + FindSymbolsResponses, + FindTextErrors, + FindTextResponses, + FormatterStatusErrors, + FormatterStatusResponses, + GlobalConfigGetErrors, + GlobalConfigGetResponses, + GlobalConfigUpdateErrors, + GlobalConfigUpdateResponses, + GlobalDisposeErrors, + GlobalDisposeResponses, + GlobalEventErrors, + GlobalEventResponses, + GlobalHealthErrors, + GlobalHealthResponses, + GlobalUpgradeErrors, + GlobalUpgradeResponses, + InstanceDisposeErrors, + InstanceDisposeResponses, + LocationRef, + LspStatusErrors, + LspStatusResponses, + McpAddErrors, + McpAddResponses, + McpAuthAuthenticateErrors, + McpAuthAuthenticateResponses, + McpAuthCallbackErrors, + McpAuthCallbackResponses, + McpAuthRemoveErrors, + McpAuthRemoveResponses, + McpAuthStartErrors, + McpAuthStartResponses, + McpConnectErrors, + McpConnectResponses, + McpDisconnectErrors, + McpDisconnectResponses, + McpLocalConfig, + McpRemoteConfig, + McpStatusErrors, + McpStatusResponses, + ModelRef, + MoveSessionDestination, + OutputFormat, + Part as Part2, + PartDeleteErrors, + PartDeleteResponses, + PartUpdateErrors, + PartUpdateResponses, + PathGetErrors, + PathGetResponses, + PermissionListErrors, + PermissionListResponses, + PermissionReplyErrors, + PermissionReplyResponses, + PermissionRespondErrors, + PermissionRespondResponses, + PermissionRuleset, + PermissionV2Reply, + PermissionV2Source, + ProjectCommands, + ProjectCurrentErrors, + ProjectCurrentResponses, + ProjectDirectoriesErrors, + ProjectDirectoriesResponses, + ProjectIcon, + ProjectInitGitErrors, + ProjectInitGitResponses, + ProjectListErrors, + ProjectListResponses, + ProjectUpdateErrors, + ProjectUpdateResponses, + PromptInput, + ProviderAuthErrors, + ProviderAuthResponses, + ProviderListErrors, + ProviderListResponses, + ProviderOauthAuthorizeErrors, + ProviderOauthAuthorizeResponses, + ProviderOauthCallbackErrors, + ProviderOauthCallbackResponses, + PtyConnectErrors, + PtyConnectResponses, + PtyConnectTokenErrors, + PtyConnectTokenResponses, + PtyCreateErrors, + PtyCreateResponses, + PtyGetErrors, + PtyGetResponses, + PtyListErrors, + PtyListResponses, + PtyRemoveErrors, + PtyRemoveResponses, + PtyShellsErrors, + PtyShellsResponses, + PtyUpdateErrors, + PtyUpdateResponses, + QuestionAnswer, + QuestionListErrors, + QuestionListResponses, + QuestionRejectErrors, + QuestionRejectResponses, + QuestionReplyErrors, + QuestionReplyResponses, + QuestionV2Reply, + SessionAbortErrors, + SessionAbortResponses, + SessionChildrenErrors, + SessionChildrenResponses, + SessionCommandErrors, + SessionCommandResponses, + SessionCreateErrors, + SessionCreateResponses, + SessionDeleteErrors, + SessionDeleteMessageErrors, + SessionDeleteMessageResponses, + SessionDeleteResponses, + SessionDiffErrors, + SessionDiffResponses, + SessionForkErrors, + SessionForkResponses, + SessionGetErrors, + SessionGetResponses, + SessionInitErrors, + SessionInitResponses, + SessionListErrors, + SessionListResponses, + SessionMessageErrors, + SessionMessageResponses, + SessionMessagesErrors, + SessionMessagesResponses, + SessionPromptAsyncErrors, + SessionPromptAsyncResponses, + SessionPromptErrors, + SessionPromptResponses, + SessionRevertErrors, + SessionRevertResponses, + SessionShareErrors, + SessionShareResponses, + SessionShellErrors, + SessionShellResponses, + SessionStatusErrors, + SessionStatusResponses, + SessionSummarizeErrors, + SessionSummarizeResponses, + SessionTodoErrors, + SessionTodoResponses, + SessionUnrevertErrors, + SessionUnrevertResponses, + SessionUnshareErrors, + SessionUnshareResponses, + SessionUpdateErrors, + SessionUpdateResponses, + SubtaskPartInput, + SyncHistoryListErrors, + SyncHistoryListResponses, + SyncReplayErrors, + SyncReplayResponses, + SyncStartErrors, + SyncStartResponses, + SyncStealErrors, + SyncStealResponses, + TextPartInput, + ToolIdsErrors, + ToolIdsResponses, + ToolListErrors, + ToolListResponses, + TuiAppendPromptErrors, + TuiAppendPromptResponses, + TuiClearPromptErrors, + TuiClearPromptResponses, + TuiControlNextErrors, + TuiControlNextResponses, + TuiControlResponseErrors, + TuiControlResponseResponses, + TuiExecuteCommandErrors, + TuiExecuteCommandResponses, + TuiOpenHelpErrors, + TuiOpenHelpResponses, + TuiOpenModelsErrors, + TuiOpenModelsResponses, + TuiOpenSessionsErrors, + TuiOpenSessionsResponses, + TuiOpenThemesErrors, + TuiOpenThemesResponses, + TuiPublishErrors, + TuiPublishResponses, + TuiSelectSessionErrors, + TuiSelectSessionResponses, + TuiShowToastErrors, + TuiShowToastResponses, + TuiSubmitPromptErrors, + TuiSubmitPromptResponses, + V2AgentListErrors, + V2AgentListResponses, + V2CommandListErrors, + V2CommandListResponses, + V2CredentialRemoveErrors, + V2CredentialRemoveResponses, + V2CredentialUpdateErrors, + V2CredentialUpdateResponses, + V2EventSubscribeErrors, + V2EventSubscribeResponses, + V2FsFindErrors, + V2FsFindResponses, + V2FsListErrors, + V2FsListResponses, + V2FsReadErrors, + V2FsReadResponses, + V2HealthGetErrors, + V2HealthGetResponses, + V2IntegrationAttemptCancelErrors, + V2IntegrationAttemptCancelResponses, + V2IntegrationAttemptCompleteErrors, + V2IntegrationAttemptCompleteResponses, + V2IntegrationAttemptStatusErrors, + V2IntegrationAttemptStatusResponses, + V2IntegrationConnectKeyErrors, + V2IntegrationConnectKeyResponses, + V2IntegrationConnectOauthErrors, + V2IntegrationConnectOauthResponses, + V2IntegrationGetErrors, + V2IntegrationGetResponses, + V2IntegrationListErrors, + V2IntegrationListResponses, + V2LocationGetErrors, + V2LocationGetResponses, + V2ModelListErrors, + V2ModelListResponses, + V2PermissionRequestListErrors, + V2PermissionRequestListResponses, + V2PermissionSavedListErrors, + V2PermissionSavedListResponses, + V2PermissionSavedRemoveErrors, + V2PermissionSavedRemoveResponses, + V2ProjectCopyCreateErrors, + V2ProjectCopyCreateResponses, + V2ProjectCopyRefreshErrors, + V2ProjectCopyRefreshResponses, + V2ProjectCopyRemoveErrors, + V2ProjectCopyRemoveResponses, + V2ProviderGetErrors, + V2ProviderGetResponses, + V2ProviderListErrors, + V2ProviderListResponses, + V2PtyConnectErrors, + V2PtyConnectResponses, + V2PtyConnectTokenErrors, + V2PtyConnectTokenResponses, + V2PtyCreateErrors, + V2PtyCreateResponses, + V2PtyGetErrors, + V2PtyGetResponses, + V2PtyListErrors, + V2PtyListResponses, + V2PtyRemoveErrors, + V2PtyRemoveResponses, + V2PtyUpdateErrors, + V2PtyUpdateResponses, + V2QuestionRequestListErrors, + V2QuestionRequestListResponses, + V2ReferenceListErrors, + V2ReferenceListResponses, + V2SessionActiveErrors, + V2SessionActiveResponses, + V2SessionCompactErrors, + V2SessionCompactResponses, + V2SessionContextErrors, + V2SessionContextResponses, + V2SessionCreateErrors, + V2SessionCreateResponses, + V2SessionEventsErrors, + V2SessionEventsResponses, + V2SessionGetErrors, + V2SessionGetResponses, + V2SessionHistoryErrors, + V2SessionHistoryResponses, + V2SessionInterruptErrors, + V2SessionInterruptResponses, + V2SessionListErrors, + V2SessionListResponses, + V2SessionMessageErrors, + V2SessionMessageResponses, + V2SessionMessagesErrors, + V2SessionMessagesResponses, + V2SessionPermissionCreateErrors, + V2SessionPermissionCreateResponses, + V2SessionPermissionGetErrors, + V2SessionPermissionGetResponses, + V2SessionPermissionListErrors, + V2SessionPermissionListResponses, + V2SessionPermissionReplyErrors, + V2SessionPermissionReplyResponses, + V2SessionPromptErrors, + V2SessionPromptResponses, + V2SessionQuestionListErrors, + V2SessionQuestionListResponses, + V2SessionQuestionRejectErrors, + V2SessionQuestionRejectResponses, + V2SessionQuestionReplyErrors, + V2SessionQuestionReplyResponses, + V2SessionRevertClearErrors, + V2SessionRevertClearResponses, + V2SessionRevertCommitErrors, + V2SessionRevertCommitResponses, + V2SessionRevertStageErrors, + V2SessionRevertStageResponses, + V2SessionSwitchAgentErrors, + V2SessionSwitchAgentResponses, + V2SessionSwitchModelErrors, + V2SessionSwitchModelResponses, + V2SessionWaitErrors, + V2SessionWaitResponses, + V2SkillListErrors, + V2SkillListResponses, + VcsApplyErrors, + VcsApplyResponses, + VcsDiffErrors, + VcsDiffRawErrors, + VcsDiffRawResponses, + VcsDiffResponses, + VcsGetErrors, + VcsGetResponses, + VcsStatusErrors, + VcsStatusResponses, + WorktreeCreateErrors, + WorktreeCreateInput, + WorktreeCreateResponses, + WorktreeListErrors, + WorktreeListResponses, + WorktreeRemoveErrors, + WorktreeRemoveInput, + WorktreeRemoveResponses, + WorktreeResetErrors, + WorktreeResetInput, + WorktreeResetResponses, +} from "./types.gen.js" + +export type Options = Options2< + TData, + ThrowOnError +> & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record +} + +class HeyApiClient { + protected client: Client + + constructor(args?: { client?: Client }) { + this.client = args?.client ?? client + } +} + +class HeyApiRegistry { + private readonly defaultKey = "default" + + private readonly instances: Map = new Map() + + get(key?: string): T { + const instance = this.instances.get(key ?? this.defaultKey) + if (!instance) { + throw new Error(`No SDK client found. Create one with "new OpencodeClient()" to fix this error.`) + } + return instance + } + + set(value: T, key?: string): void { + this.instances.set(key ?? this.defaultKey, value) + } +} + +export class Auth extends HeyApiClient { + /** + * Remove auth credentials + * + * Remove authentication credentials + */ + public remove( + parameters: { + providerID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }]) + return (options?.client ?? this.client).delete({ + url: "/auth/{providerID}", + ...options, + ...params, + }) + } + + /** + * Set auth credentials + * + * Set authentication credentials + */ + public set( + parameters: { + providerID: string + auth?: Auth3 + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { key: "auth", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/auth/{providerID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class App extends HeyApiClient { + /** + * Write log + * + * Write a log entry to the server logs with specified level and metadata. + */ + public log( + parameters?: { + directory?: string + workspace?: string + service?: string + level?: "debug" | "info" | "error" | "warn" + message?: string + extra?: { + [key: string]: unknown + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "service" }, + { in: "body", key: "level" }, + { in: "body", key: "message" }, + { in: "body", key: "extra" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/log", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List agents + * + * Get a list of all available AI agents in the OpenCode system. + */ + public agents( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/agent", + ...options, + ...params, + }) + } + + /** + * List skills + * + * Get a list of all available skills in the OpenCode system. + */ + public skills( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/skill", + ...options, + ...params, + }) + } +} + +export class ControlPlane extends HeyApiClient { + /** + * Move session + * + * Move a session to another project directory, optionally transferring local changes. + */ + public moveSession( + parameters?: { + sessionID?: string + destination?: MoveSessionDestination + moveChanges?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "body", key: "sessionID" }, + { in: "body", key: "destination" }, + { in: "body", key: "moveChanges" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalControlPlaneMoveSessionResponses, + ExperimentalControlPlaneMoveSessionErrors, + ThrowOnError + >({ + url: "/experimental/control-plane/move-session", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Capabilities extends HeyApiClient { + /** + * Get experimental capabilities + * + * Get experimental features enabled on the OpenCode server. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalCapabilitiesGetResponses, + ExperimentalCapabilitiesGetErrors, + ThrowOnError + >({ + url: "/experimental/capabilities", + ...options, + ...params, + }) + } +} + +export class Console extends HeyApiClient { + /** + * Get active Console provider metadata + * + * Get the active Console org name and the set of provider IDs managed by that Console org. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalConsoleGetResponses, + ExperimentalConsoleGetErrors, + ThrowOnError + >({ + url: "/experimental/console", + ...options, + ...params, + }) + } + + /** + * List switchable Console orgs + * + * Get the available Console orgs across logged-in accounts, including the current active org. + */ + public listOrgs( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalConsoleListOrgsResponses, + ExperimentalConsoleListOrgsErrors, + ThrowOnError + >({ + url: "/experimental/console/orgs", + ...options, + ...params, + }) + } + + /** + * Switch active Console org + * + * Persist a new active Console account/org selection for the current local OpenCode state. + */ + public switchOrg( + parameters?: { + directory?: string + workspace?: string + accountID?: string + orgID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "accountID" }, + { in: "body", key: "orgID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/console/switch", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Session extends HeyApiClient { + /** + * List sessions + * + * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + */ + public list( + parameters?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "cursor" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, + { in: "query", key: "archived" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalSessionListResponses, + ExperimentalSessionListErrors, + ThrowOnError + >({ + url: "/experimental/session", + ...options, + ...params, + }) + } + + /** + * Background subagents + * + * Detach any synchronous subagents currently blocking the session and continue them in the background. + */ + public background( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalSessionBackgroundResponses, + ExperimentalSessionBackgroundErrors, + ThrowOnError + >({ + url: "/experimental/session/{sessionID}/background", + ...options, + ...params, + }) + } +} + +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalResourceListResponses, + ExperimentalResourceListErrors, + ThrowOnError + >({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + +export class ProjectCopy extends HeyApiClient { + /** + * Generate project copy name + * + * Generate a short name for a project copy from task context. + */ + public generateName( + parameters: { + projectID: string + directory?: string + workspace?: string + context?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "context" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalProjectCopyGenerateNameResponses, + ExperimentalProjectCopyGenerateNameErrors, + ThrowOnError + >({ + url: "/experimental/project/{projectID}/copy/generate-name", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Adapter extends HeyApiClient { + /** + * List workspace adapters + * + * List all available workspace adapters for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalWorkspaceAdapterListResponses, + ExperimentalWorkspaceAdapterListErrors, + ThrowOnError + >({ + url: "/experimental/workspace/adapter", + ...options, + ...params, + }) + } +} + +export class Workspace extends HeyApiClient { + /** + * List workspaces + * + * List all workspaces. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalWorkspaceListResponses, + ExperimentalWorkspaceListErrors, + ThrowOnError + >({ + url: "/experimental/workspace", + ...options, + ...params, + }) + } + + /** + * Create workspace + * + * Create a workspace for the current project. + */ + public create( + parameters?: { + directory?: string + workspace?: string + id?: string + type?: string + branch?: string | null + extra?: unknown | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "type" }, + { in: "body", key: "branch" }, + { in: "body", key: "extra" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceCreateErrors, + ThrowOnError + >({ + url: "/experimental/workspace", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Sync workspace list + * + * Register missing workspaces returned by workspace adapters. + */ + public syncList( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceSyncListResponses, + ExperimentalWorkspaceSyncListErrors, + ThrowOnError + >({ + url: "/experimental/workspace/sync-list", + ...options, + ...params, + }) + } + + /** + * Workspace status + * + * Get connection status for workspaces in the current project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceStatusErrors, + ThrowOnError + >({ + url: "/experimental/workspace/status", + ...options, + ...params, + }) + } + + /** + * Remove workspace + * + * Remove an existing workspace. + */ + public remove( + parameters: { + id: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceRemoveErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}", + ...options, + ...params, + }) + } + + /** + * Warp session into workspace + * + * Move a session's sync history into the target workspace, or detach it to the local project. + */ + public warp( + parameters?: { + directory?: string + workspace?: string + id?: string | null + sessionID?: string + copyChanges?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "sessionID" }, + { in: "body", key: "copyChanges" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceWarpResponses, + ExperimentalWorkspaceWarpErrors, + ThrowOnError + >({ + url: "/experimental/workspace/warp", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _adapter?: Adapter + get adapter(): Adapter { + return (this._adapter ??= new Adapter({ client: this.client })) + } +} + +export class Experimental extends HeyApiClient { + private _controlPlane?: ControlPlane + get controlPlane(): ControlPlane { + return (this._controlPlane ??= new ControlPlane({ client: this.client })) + } + + private _capabilities?: Capabilities + get capabilities(): Capabilities { + return (this._capabilities ??= new Capabilities({ client: this.client })) + } + + private _console?: Console + get console(): Console { + return (this._console ??= new Console({ client: this.client })) + } + + private _session?: Session + get session(): Session { + return (this._session ??= new Session({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } + + private _projectCopy?: ProjectCopy + get projectCopy(): ProjectCopy { + return (this._projectCopy ??= new ProjectCopy({ client: this.client })) + } + + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) + } +} + +export class Config extends HeyApiClient { + /** + * Get global configuration + * + * Retrieve the current global OpenCode configuration settings and preferences. + */ + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/config", + ...options, + }) + } + + /** + * Update global configuration + * + * Update global OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) + return (options?.client ?? this.client).patch({ + url: "/global/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Global extends HeyApiClient { + /** + * Get health + * + * Get health information about the OpenCode server. + */ + public health(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/health", + ...options, + }) + } + + /** + * Get global events + * + * Subscribe to global events from the OpenCode system using server-sent events. + */ + public event(options?: Options) { + return (options?.client ?? this.client).sse.get({ + url: "/global/event", + ...options, + }) + } + + /** + * Dispose instance + * + * Clean up and dispose all OpenCode instances, releasing all resources. + */ + public dispose(options?: Options) { + return (options?.client ?? this.client).post({ + url: "/global/dispose", + ...options, + }) + } + + /** + * Upgrade opencode + * + * Upgrade opencode to the specified version. + */ + public upgrade( + parameters?: { + target?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) + return (options?.client ?? this.client).post({ + url: "/global/upgrade", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _config?: Config + get config(): Config { + return (this._config ??= new Config({ client: this.client })) + } +} + +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/event", + ...options, + ...params, + }) + } +} + +export class Config2 extends HeyApiClient { + /** + * Get configuration + * + * Retrieve the current OpenCode configuration settings and preferences. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config", + ...options, + ...params, + }) + } + + /** + * Update configuration + * + * Update OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + directory?: string + workspace?: string + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "config", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List config providers + * + * Get a list of all configured AI providers and their default models. + */ + public providers( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config/providers", + ...options, + ...params, + }) + } +} + +export class Tool extends HeyApiClient { + /** + * List tools + * + * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + */ + public list( + parameters: { + directory?: string + workspace?: string + provider: string + model: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "provider" }, + { in: "query", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool", + ...options, + ...params, + }) + } + + /** + * List tool IDs + * + * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. + */ + public ids( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool/ids", + ...options, + ...params, + }) + } +} + +export class Worktree extends HeyApiClient { + /** + * Remove worktree + * + * Remove a git worktree and delete its branch. + */ + public remove( + parameters?: { + directory?: string + workspace?: string + worktreeRemoveInput?: WorktreeRemoveInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeRemoveInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List worktrees + * + * List all sandbox worktrees for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/worktree", + ...options, + ...params, + }) + } + + /** + * Create worktree + * + * Create a new git worktree for the current project and run any configured startup scripts. + */ + public create( + parameters?: { + directory?: string + workspace?: string + worktreeCreateInput?: WorktreeCreateInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeCreateInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reset worktree + * + * Reset a worktree branch to the primary default branch. + */ + public reset( + parameters?: { + directory?: string + workspace?: string + worktreeResetInput?: WorktreeResetInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeResetInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree/reset", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Find extends HeyApiClient { + /** + * Find text + * + * Search for text patterns across files in the project using ripgrep. + */ + public text( + parameters: { + directory?: string + workspace?: string + pattern: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "pattern" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find", + ...options, + ...params, + }) + } + + /** + * Find files + * + * Search for files or directories by name or pattern in the project directory. + */ + public files( + parameters: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + { in: "query", key: "dirs" }, + { in: "query", key: "type" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/file", + ...options, + ...params, + }) + } + + /** + * Find symbols + * + * Search for workspace symbols like functions, classes, and variables using LSP. + */ + public symbols( + parameters: { + directory?: string + workspace?: string + query: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/symbol", + ...options, + ...params, + }) + } +} + +export class File extends HeyApiClient { + /** + * List files + * + * List files and directories in a specified path. + */ + public list( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file", + ...options, + ...params, + }) + } + + /** + * Read file + * + * Read the content of a specified file. + */ + public read( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/content", + ...options, + ...params, + }) + } + + /** + * Get file status + * + * Get the git status of all files in the project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/status", + ...options, + ...params, + }) + } +} + +export class Instance extends HeyApiClient { + /** + * Dispose instance + * + * Clean up and dispose the current OpenCode instance, releasing all resources. + */ + public dispose( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/instance/dispose", + ...options, + ...params, + }) + } +} + +export class Path extends HeyApiClient { + /** + * Get paths + * + * Retrieve the current working directory and related path information for the OpenCode instance. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/path", + ...options, + ...params, + }) + } +} + +export class Diff extends HeyApiClient { + /** + * Get raw VCS diff + * + * Retrieve a raw patch for current uncommitted changes. + */ + public raw( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff/raw", + ...options, + ...params, + }) + } +} + +export class Vcs extends HeyApiClient { + /** + * Get VCS info + * + * Retrieve version control system (VCS) information for the current project, such as git branch. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs", + ...options, + ...params, + }) + } + + /** + * Get VCS status + * + * Retrieve changed files in the current working tree without patches. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/status", + ...options, + ...params, + }) + } + + /** + * Get VCS diff + * + * Retrieve the current git diff for the working tree or against the default branch. + */ + public diff( + parameters: { + directory?: string + workspace?: string + mode: "git" | "branch" + context?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "mode" }, + { in: "query", key: "context" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff", + ...options, + ...params, + }) + } + + /** + * Apply VCS patch + * + * Apply a raw patch to the current working tree. + */ + public apply( + parameters?: { + directory?: string + workspace?: string + patch?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "patch" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/apply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _diff?: Diff + get diff2(): Diff { + return (this._diff ??= new Diff({ client: this.client })) + } +} + +export class Command extends HeyApiClient { + /** + * List commands + * + * Get a list of all available commands in the OpenCode system. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/command", + ...options, + ...params, + }) + } +} + +export class Lsp extends HeyApiClient { + /** + * Get LSP status + * + * Get LSP server status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/lsp", + ...options, + ...params, + }) + } +} + +export class Formatter extends HeyApiClient { + /** + * Get formatter status + * + * Get formatter status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/formatter", + ...options, + ...params, + }) + } +} + +export class Auth2 extends HeyApiClient { + /** + * Remove MCP OAuth + * + * Remove OAuth credentials for an MCP server. + */ + public remove( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Start MCP OAuth + * + * Start OAuth authentication flow for a Model Context Protocol (MCP) server. + */ + public start( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Complete MCP OAuth + * + * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. + */ + public callback( + parameters: { + name: string + directory?: string + workspace?: string + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth/callback", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Authenticate MCP OAuth + * + * Start OAuth flow and wait for callback (opens browser). + */ + public authenticate( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + ...params, + }, + ) + } +} + +export class Mcp extends HeyApiClient { + /** + * Get MCP status + * + * Get the status of all Model Context Protocol (MCP) servers. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/mcp", + ...options, + ...params, + }) + } + + /** + * Add MCP server + * + * Dynamically add a new Model Context Protocol (MCP) server to the system. + */ + public add( + parameters?: { + directory?: string + workspace?: string + name?: string + config?: McpLocalConfig | McpRemoteConfig + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Connect an MCP server. + */ + public connect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/connect", + ...options, + ...params, + }) + } + + /** + * Disconnect an MCP server. + */ + public disconnect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/disconnect", + ...options, + ...params, + }) + } + + private _auth?: Auth2 + get auth(): Auth2 { + return (this._auth ??= new Auth2({ client: this.client })) + } +} + +export class Project extends HeyApiClient { + /** + * List all projects + * + * Get a list of projects that have been opened with OpenCode. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project", + ...options, + ...params, + }) + } + + /** + * Get current project + * + * Retrieve the currently active project that OpenCode is working with. + */ + public current( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project/current", + ...options, + ...params, + }) + } + + /** + * Initialize git repository + * + * Create a git repository for the current project and return the refreshed project info. + */ + public initGit( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/project/git/init", + ...options, + ...params, + }) + } + + /** + * Update project + * + * Update project properties such as name, icon, and commands. + */ + public update( + parameters: { + projectID: string + directory?: string + workspace?: string + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "icon" }, + { in: "body", key: "commands" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/project/{projectID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List project directories + * + * List known local absolute directories for a project. + */ + public directories( + parameters: { + projectID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project/{projectID}/directories", + ...options, + ...params, + }) + } +} + +export class Pty extends HeyApiClient { + /** + * List available shells + * + * Get a list of available shells on the system. + */ + public shells( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/pty/shells", + ...options, + ...params, + }) + } + + /** + * List PTY sessions + * + * Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/pty", + ...options, + ...params, + }) + } + + /** + * Create PTY session + * + * Create a new pseudo-terminal (PTY) session for running shell commands and processes. + */ + public create( + parameters?: { + directory?: string + workspace?: string + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "command" }, + { in: "body", key: "args" }, + { in: "body", key: "cwd" }, + { in: "body", key: "title" }, + { in: "body", key: "env" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/pty", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove PTY session + * + * Remove and terminate a specific pseudo-terminal (PTY) session. + */ + public remove( + parameters: { + ptyID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/pty/{ptyID}", + ...options, + ...params, + }) + } + + /** + * Get PTY session + * + * Retrieve detailed information about a specific pseudo-terminal (PTY) session. + */ + public get( + parameters: { + ptyID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/pty/{ptyID}", + ...options, + ...params, + }) + } + + /** + * Update PTY session + * + * Update properties of an existing pseudo-terminal (PTY) session. + */ + public update( + parameters: { + ptyID: string + directory?: string + workspace?: string + title?: string + size?: { + rows: number + cols: number + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "title" }, + { in: "body", key: "size" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/pty/{ptyID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Create PTY WebSocket token + * + * Create a short-lived ticket for opening a PTY WebSocket connection. + */ + public connectToken( + parameters: { + ptyID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } + + /** + * Connect to PTY session + * + * Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time. + */ + public connect( + parameters: { + ptyID: string + directory?: string + workspace?: string + cursor?: string + ticket?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "cursor" }, + { in: "query", key: "ticket" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/pty/{ptyID}/connect", + ...options, + ...params, + }) + } +} + +export class Question extends HeyApiClient { + /** + * List pending questions + * + * Get all pending question requests across all sessions. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/question", + ...options, + ...params, + }) + } + + /** + * Reply to question request + * + * Provide answers to a question request from the AI assistant. + */ + public reply( + parameters: { + requestID: string + directory?: string + workspace?: string + answers?: Array + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "answers" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reject question request + * + * Reject a question request from the AI assistant. + */ + public reject( + parameters: { + requestID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reject", + ...options, + ...params, + }) + } +} + +export class Permission extends HeyApiClient { + /** + * List pending permissions + * + * Get all pending permission requests across all sessions. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/permission", + ...options, + ...params, + }) + } + + /** + * Respond to permission request + * + * Approve or deny a permission request from the AI assistant. + */ + public reply( + parameters: { + requestID: string + directory?: string + workspace?: string + reply?: "once" | "always" | "reject" + message?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/permission/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Respond to permission + * + * Approve or deny a permission request from the AI assistant. + * + * @deprecated + */ + public respond( + parameters: { + sessionID: string + permissionID: string + directory?: string + workspace?: string + response?: "once" | "always" | "reject" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "permissionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "response" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/permissions/{permissionID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Oauth extends HeyApiClient { + /** + * Start OAuth authorization + * + * Start the OAuth authorization flow for a provider. + */ + public authorize( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + inputs?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "inputs" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/authorize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Handle OAuth callback + * + * Handle the OAuth callback from a provider after user authorization. + */ + public callback( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/callback", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Provider extends HeyApiClient { + /** + * List providers + * + * Get a list of all available AI providers, including both available and connected ones. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/provider", + ...options, + ...params, + }) + } + + /** + * Get provider auth methods + * + * Retrieve available authentication methods for all AI providers. + */ + public auth( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/provider/auth", + ...options, + ...params, + }) + } + + private _oauth?: Oauth + get oauth(): Oauth { + return (this._oauth ??= new Oauth({ client: this.client })) + } +} + +export class Session2 extends HeyApiClient { + /** + * List sessions + * + * Get a list of all OpenCode sessions, sorted by most recently updated. + */ + public list( + parameters?: { + directory?: string + workspace?: string + scope?: "project" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "scope" }, + { in: "query", key: "path" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session", + ...options, + ...params, + }) + } + + /** + * Create session + * + * Create a new OpenCode session for interacting with AI assistants and managing conversations. + */ + public create( + parameters?: { + directory?: string + workspace?: string + parentID?: string + title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + metadata?: { + [key: string]: unknown + } + permission?: PermissionRuleset + workspaceID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "parentID" }, + { in: "body", key: "title" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "metadata" }, + { in: "body", key: "permission" }, + { in: "body", key: "workspaceID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get session status + * + * Retrieve the current status of all sessions, including active, idle, and completed states. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/status", + ...options, + ...params, + }) + } + + /** + * Delete session + * + * Delete a session and permanently remove all associated data, including messages and history. + */ + public delete( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}", + ...options, + ...params, + }) + } + + /** + * Get session + * + * Retrieve detailed information about a specific OpenCode session. + */ + public get( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}", + ...options, + ...params, + }) + } + + /** + * Update session + * + * Update properties of an existing session, such as title or other metadata. + */ + public update( + parameters: { + sessionID: string + directory?: string + workspace?: string + title?: string + metadata?: { + [key: string]: unknown + } + permission?: PermissionRuleset + time?: { + archived?: number + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "title" }, + { in: "body", key: "metadata" }, + { in: "body", key: "permission" }, + { in: "body", key: "time" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/session/{sessionID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get session children + * + * Retrieve all child sessions that were forked from the specified parent session. + */ + public children( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/children", + ...options, + ...params, + }) + } + + /** + * Get session todos + * + * Retrieve the todo list associated with a specific session, showing tasks and action items. + */ + public todo( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/todo", + ...options, + ...params, + }) + } + + /** + * Get message diff + * + * Get the file changes (diff) that resulted from a specific user message in the session. + */ + public diff( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/diff", + ...options, + ...params, + }) + } + + /** + * Get session messages + * + * Retrieve all messages in a session, including user prompts and AI responses. + */ + public messages( + parameters: { + sessionID: string + directory?: string + workspace?: string + limit?: number + before?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "before" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/message", + ...options, + ...params, + }) + } + + /** + * Send message + * + * Create and send a new message to a session, streaming the AI response. + */ + public prompt( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + tools?: { + [key: string]: boolean + } + format?: OutputFormat + system?: string + variant?: string + parts?: Array + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "model" }, + { in: "body", key: "agent" }, + { in: "body", key: "noReply" }, + { in: "body", key: "tools" }, + { in: "body", key: "format" }, + { in: "body", key: "system" }, + { in: "body", key: "variant" }, + { in: "body", key: "parts" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/message", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Delete message + * + * Permanently delete a specific message and all of its parts from a session without reverting file changes. + */ + public deleteMessage( + parameters: { + sessionID: string + messageID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + SessionDeleteMessageResponses, + SessionDeleteMessageErrors, + ThrowOnError + >({ + url: "/session/{sessionID}/message/{messageID}", + ...options, + ...params, + }) + } + + /** + * Get message + * + * Retrieve a specific message from a session by its message ID. + */ + public message( + parameters: { + sessionID: string + messageID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/message/{messageID}", + ...options, + ...params, + }) + } + + /** + * Fork session + * + * Create a new session by forking an existing session at a specific message point. + */ + public fork( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/fork", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Abort session + * + * Abort an active session and stop any ongoing AI processing or command execution. + */ + public abort( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/abort", + ...options, + ...params, + }) + } + + /** + * Initialize session + * + * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. + */ + public init( + parameters: { + sessionID: string + directory?: string + workspace?: string + modelID?: string + providerID?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "modelID" }, + { in: "body", key: "providerID" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/init", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Unshare session + * + * Remove the shareable link for a session, making it private again. + */ + public unshare( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Share session + * + * Create a shareable link for a session, allowing others to view the conversation. + */ + public share( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Summarize session + * + * Generate a concise summary of the session using AI compaction to preserve key information. + */ + public summarize( + parameters: { + sessionID: string + directory?: string + workspace?: string + providerID?: string + modelID?: string + auto?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "providerID" }, + { in: "body", key: "modelID" }, + { in: "body", key: "auto" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/summarize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Send async message + * + * Create and send a new message to a session asynchronously, starting the session if needed and returning immediately. + */ + public promptAsync( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + tools?: { + [key: string]: boolean + } + format?: OutputFormat + system?: string + variant?: string + parts?: Array + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "model" }, + { in: "body", key: "agent" }, + { in: "body", key: "noReply" }, + { in: "body", key: "tools" }, + { in: "body", key: "format" }, + { in: "body", key: "system" }, + { in: "body", key: "variant" }, + { in: "body", key: "parts" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/prompt_async", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Send command + * + * Send a new command to a session for execution by the AI assistant. + */ + public command( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + agent?: string + model?: string + arguments?: string + command?: string + variant?: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource + }> + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "arguments" }, + { in: "body", key: "command" }, + { in: "body", key: "variant" }, + { in: "body", key: "parts" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/command", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Run shell command + * + * Execute a shell command within the session context and return the AI's response. + */ + public shell( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + agent?: string + model?: { + providerID: string + modelID: string + } + command?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "command" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/shell", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Revert message + * + * Revert a specific message in a session, undoing its effects and restoring the previous state. + */ + public revert( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + partID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "partID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/revert", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Restore reverted messages + * + * Restore all previously reverted messages in a session. + */ + public unrevert( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/unrevert", + ...options, + ...params, + }) + } +} + +export class Part extends HeyApiClient { + /** + * Delete a part from a message. + */ + public delete( + parameters: { + sessionID: string + messageID: string + partID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + { in: "path", key: "partID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}/message/{messageID}/part/{partID}", + ...options, + ...params, + }) + } + + /** + * Update a part in a message. + */ + public update( + parameters: { + sessionID: string + messageID: string + partID: string + directory?: string + workspace?: string + part?: Part2 + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + { in: "path", key: "partID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "part", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/session/{sessionID}/message/{messageID}/part/{partID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class History extends HeyApiClient { + /** + * List sync events + * + * List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history. + */ + public list( + parameters?: { + directory?: string + workspace?: string + body?: { + [key: string]: number + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "body", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/history", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Sync extends HeyApiClient { + /** + * Start workspace sync + * + * Start sync loops for workspaces in the current project that have active sessions. + */ + public start( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/start", + ...options, + ...params, + }) + } + + /** + * Replay sync events + * + * Validate and replay a complete sync event history. + */ + public replay( + parameters?: { + query_directory?: string + workspace?: string + body_directory?: string + events?: Array<{ + id: string + aggregateID: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { + in: "query", + key: "query_directory", + map: "directory", + }, + { in: "query", key: "workspace" }, + { + in: "body", + key: "body_directory", + map: "directory", + }, + { in: "body", key: "events" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/replay", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Steal session into workspace + * + * Update a session to belong to the current workspace through the sync event system. + */ + public steal( + parameters?: { + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/steal", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _history?: History + get history(): History { + return (this._history ??= new History({ client: this.client })) + } +} + +export class Control extends HeyApiClient { + /** + * Get next TUI request + * + * Retrieve the next TUI request from the queue for processing. + */ + public next( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/tui/control/next", + ...options, + ...params, + }) + } + + /** + * Submit TUI response + * + * Submit a response to the TUI request queue to complete a pending request. + */ + public response( + parameters?: { + directory?: string + workspace?: string + body?: unknown + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "body", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/control/response", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Tui extends HeyApiClient { + /** + * Append TUI prompt + * + * Append prompt to the TUI. + */ + public appendPrompt( + parameters?: { + directory?: string + workspace?: string + text?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "text" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/append-prompt", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Open help dialog + * + * Open the help dialog in the TUI to display user assistance information. + */ + public openHelp( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/open-help", + ...options, + ...params, + }) + } + + /** + * Open sessions dialog + * + * Open the session dialog. + */ + public openSessions( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/open-sessions", + ...options, + ...params, + }) + } + + /** + * Open themes dialog + * + * Open the theme dialog. + */ + public openThemes( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/open-themes", + ...options, + ...params, + }) + } + + /** + * Open models dialog + * + * Open the model dialog. + */ + public openModels( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/open-models", + ...options, + ...params, + }) + } + + /** + * Submit TUI prompt + * + * Submit the prompt. + */ + public submitPrompt( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/submit-prompt", + ...options, + ...params, + }) + } + + /** + * Clear TUI prompt + * + * Clear the prompt. + */ + public clearPrompt( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/clear-prompt", + ...options, + ...params, + }) + } + + /** + * Execute TUI command + * + * Execute a TUI command. + */ + public executeCommand( + parameters?: { + directory?: string + workspace?: string + command?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "command" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/execute-command", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Show TUI toast + * + * Show a toast notification in the TUI. + */ + public showToast( + parameters?: { + directory?: string + workspace?: string + title?: string + message?: string + variant?: "info" | "success" | "warning" | "error" + duration?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "title" }, + { in: "body", key: "message" }, + { in: "body", key: "variant" }, + { in: "body", key: "duration" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/show-toast", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Publish TUI event + * + * Publish a TUI event. + */ + public publish( + parameters?: { + directory?: string + workspace?: string + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "body", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/publish", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Select session + * + * Navigate the TUI to display the specified session. + */ + public selectSession( + parameters?: { + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/select-session", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _control?: Control + get control(): Control { + return (this._control ??= new Control({ client: this.client })) + } +} + +export class Health extends HeyApiClient { + /** + * Check server health + * + * Check whether the API server is ready to accept requests. + */ + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/health", + ...options, + }) + } +} + +export class Location extends HeyApiClient { + /** + * Get location + * + * Resolve the requested location or the server default location. + */ + public get( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/location", + ...options, + ...params, + }) + } +} + +export class Agent extends HeyApiClient { + /** + * List agents + * + * Retrieve currently registered agents. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/agent", + ...options, + ...params, + }) + } +} + +export class Revert extends HeyApiClient { + /** + * Stage session revert + * + * Stage or move a reversible session boundary and optionally apply its file changes. + */ + public stage( + parameters: { + sessionID: string + messageID?: string + files?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "messageID" }, + { in: "body", key: "files" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionRevertStageResponses, + V2SessionRevertStageErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/stage", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Clear staged revert + */ + public clear( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post< + V2SessionRevertClearResponses, + V2SessionRevertClearErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/clear", + ...options, + ...params, + }) + } + + /** + * Commit staged revert + */ + public commit( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post< + V2SessionRevertCommitResponses, + V2SessionRevertCommitErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/commit", + ...options, + ...params, + }) + } +} + +export class Permission2 extends HeyApiClient { + /** + * List session permission requests + * + * Retrieve pending permission requests owned by a session. + */ + public list( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get< + V2SessionPermissionListResponses, + V2SessionPermissionListErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission", + ...options, + ...params, + }) + } + + /** + * Create permission request + * + * Evaluate and, when approval is required, create a permission request for a session. + */ + public create( + parameters: { + sessionID: string + id?: string + action?: string + resources?: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "action" }, + { in: "body", key: "resources" }, + { in: "body", key: "save" }, + { in: "body", key: "metadata" }, + { in: "body", key: "source" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionPermissionCreateResponses, + V2SessionPermissionCreateErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get permission request + * + * Retrieve a pending permission request owned by a session. + */ + public get( + parameters: { + sessionID: string + requestID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + V2SessionPermissionGetResponses, + V2SessionPermissionGetErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission/{requestID}", + ...options, + ...params, + }) + } + + /** + * Reply to pending permission request + * + * Respond to a pending permission request owned by a session. + */ + public reply( + parameters: { + sessionID: string + requestID: string + reply?: PermissionV2Reply + message?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionPermissionReplyResponses, + V2SessionPermissionReplyErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Question2 extends HeyApiClient { + /** + * List session question requests + * + * Retrieve pending question requests owned by a session. + */ + public list( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get< + V2SessionQuestionListResponses, + V2SessionQuestionListErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/question", + ...options, + ...params, + }) + } + + /** + * Reply to pending question request + * + * Answer a pending question request owned by a session. + */ + public reply( + parameters: { + sessionID: string + requestID: string + questionV2Reply: QuestionV2Reply + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + { key: "questionV2Reply", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionQuestionReplyResponses, + V2SessionQuestionReplyErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/question/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reject pending question request + * + * Reject a pending question request owned by a session. + */ + public reject( + parameters: { + sessionID: string + requestID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionQuestionRejectResponses, + V2SessionQuestionRejectErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/question/{requestID}/reject", + ...options, + ...params, + }) + } +} + +export class Session3 extends HeyApiClient { + /** + * List sessions + * + * Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list. + */ + public list( + parameters?: { + workspace?: string + limit?: number + order?: "asc" | "desc" + search?: string + directory?: string + project?: string + subpath?: string + cursor?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "search" }, + { in: "query", key: "directory" }, + { in: "query", key: "project" }, + { in: "query", key: "subpath" }, + { in: "query", key: "cursor" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session", + ...options, + ...params, + }) + } + + /** + * Create session + * + * Create a session at the requested location. + */ + public create( + parameters?: { + id?: string + agent?: string + model?: ModelRef + location?: LocationRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "body", key: "id" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List active sessions + * + * Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. + */ + public active(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/session/active", + ...options, + }) + } + + /** + * Get session + * + * Retrieve a session by ID. + */ + public get( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}", + ...options, + ...params, + }) + } + + /** + * Switch session agent + * + * Switch the agent used by subsequent provider turns. + */ + public switchAgent( + parameters: { + sessionID: string + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchAgentResponses, + V2SessionSwitchAgentErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/agent", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Switch session model + * + * Switch the model used by subsequent provider turns. + */ + public switchModel( + parameters: { + sessionID: string + model?: ModelRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchModelResponses, + V2SessionSwitchModelErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Send message + * + * Durably admit one session input and schedule agent-loop execution unless resume is false. + */ + public prompt( + parameters: { + sessionID: string + id?: string + prompt?: PromptInput + delivery?: "steer" | "queue" + resume?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "prompt" }, + { in: "body", key: "delivery" }, + { in: "body", key: "resume" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/prompt", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Compact session + * + * Compact a session conversation. + */ + public compact( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/compact", + ...options, + ...params, + }) + } + + /** + * Wait for session + * + * Wait for a session agent loop to become idle. + */ + public wait( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/wait", + ...options, + ...params, + }) + } + + /** + * Get session context + * + * Retrieve the active context messages for a session (all messages after the last compaction). + */ + public context( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/context", + ...options, + ...params, + }) + } + + /** + * Get session history + * + * Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages. + */ + public history( + parameters: { + sessionID: string + limit?: number + after?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/history", + ...options, + ...params, + }) + } + + /** + * Subscribe to session events + * + * Replay durable events after an aggregate sequence, then continue with new durable events. + */ + public events( + parameters: { + sessionID: string + after?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/api/session/{sessionID}/event", + ...options, + ...params, + }) + } + + /** + * Interrupt session execution + * + * Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. + */ + public interrupt( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/interrupt", + ...options, + ...params, + }) + } + + /** + * Get session message + * + * Retrieve one projected message owned by the Session. + */ + public message( + parameters: { + sessionID: string + messageID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message/{messageID}", + ...options, + ...params, + }) + } + + /** + * Get session messages + * + * Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public messages( + parameters: { + sessionID: string + limit?: number + order?: "asc" | "desc" + cursor?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) + } + + private _revert?: Revert + get revert(): Revert { + return (this._revert ??= new Revert({ client: this.client })) + } + + private _permission?: Permission2 + get permission(): Permission2 { + return (this._permission ??= new Permission2({ client: this.client })) + } + + private _question?: Question2 + get question(): Question2 { + return (this._question ??= new Question2({ client: this.client })) + } +} + +export class Model extends HeyApiClient { + /** + * List models + * + * Retrieve available models ordered by release date. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/model", + ...options, + ...params, + }) + } +} + +export class Provider2 extends HeyApiClient { + /** + * List providers + * + * Retrieve active AI providers so clients can show provider availability and configuration. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/provider", + ...options, + ...params, + }) + } + + /** + * Get provider + * + * Retrieve a single AI provider so clients can inspect its availability and endpoint settings. + */ + public get( + parameters: { + providerID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/provider/{providerID}", + ...options, + ...params, + }) + } +} + +export class Connect extends HeyApiClient { + /** + * Connect with key + * + * Run a key authentication method and store the resulting credential. + */ + public key( + parameters: { + integrationID: string + location?: { + directory?: string + workspace?: string + } + key?: string + label?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "integrationID" }, + { in: "query", key: "location" }, + { in: "body", key: "key" }, + { in: "body", key: "label" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2IntegrationConnectKeyResponses, + V2IntegrationConnectKeyErrors, + ThrowOnError + >({ + url: "/api/integration/{integrationID}/connect/key", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Begin OAuth connection + * + * Start an OAuth attempt and return the authorization details. + */ + public oauth( + parameters: { + integrationID: string + location?: { + directory?: string + workspace?: string + } + methodID?: string + inputs?: { + [key: string]: string + } + label?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "integrationID" }, + { in: "query", key: "location" }, + { in: "body", key: "methodID" }, + { in: "body", key: "inputs" }, + { in: "body", key: "label" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2IntegrationConnectOauthResponses, + V2IntegrationConnectOauthErrors, + ThrowOnError + >({ + url: "/api/integration/{integrationID}/connect/oauth", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Attempt extends HeyApiClient { + /** + * Cancel OAuth connection + * + * Cancel an OAuth attempt and release its resources. + */ + public cancel( + parameters: { + attemptID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "attemptID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + V2IntegrationAttemptCancelResponses, + V2IntegrationAttemptCancelErrors, + ThrowOnError + >({ + url: "/api/integration/attempt/{attemptID}", + ...options, + ...params, + }) + } + + /** + * Get OAuth attempt status + * + * Poll the current status of an OAuth attempt. + */ + public status( + parameters: { + attemptID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "attemptID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + V2IntegrationAttemptStatusResponses, + V2IntegrationAttemptStatusErrors, + ThrowOnError + >({ + url: "/api/integration/attempt/{attemptID}", + ...options, + ...params, + }) + } + + /** + * Complete OAuth connection + * + * Complete a code-based OAuth attempt and store the resulting credential. + */ + public complete( + parameters: { + attemptID: string + location?: { + directory?: string + workspace?: string + } + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "attemptID" }, + { in: "query", key: "location" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2IntegrationAttemptCompleteResponses, + V2IntegrationAttemptCompleteErrors, + ThrowOnError + >({ + url: "/api/integration/attempt/{attemptID}/complete", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Integration extends HeyApiClient { + /** + * List integrations + * + * Retrieve available integrations and their authentication methods. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/integration", + ...options, + ...params, + }) + } + + /** + * Get integration + * + * Retrieve one integration and its authentication methods. + */ + public get( + parameters: { + integrationID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "integrationID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/integration/{integrationID}", + ...options, + ...params, + }) + } + + private _connect?: Connect + get connect(): Connect { + return (this._connect ??= new Connect({ client: this.client })) + } + + private _attempt?: Attempt + get attempt(): Attempt { + return (this._attempt ??= new Attempt({ client: this.client })) + } +} + +export class Credential extends HeyApiClient { + /** + * Remove credential + * + * Remove a stored integration credential. + */ + public remove( + parameters: { + credentialID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "credentialID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete( + { + url: "/api/credential/{credentialID}", + ...options, + ...params, + }, + ) + } + + /** + * Update credential + * + * Update a stored credential label. + */ + public update( + parameters: { + credentialID: string + location?: { + directory?: string + workspace?: string + } + label?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "credentialID" }, + { in: "query", key: "location" }, + { in: "body", key: "label" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/api/credential/{credentialID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Request extends HeyApiClient { + /** + * List pending permission requests + * + * Retrieve pending permission requests for a location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2PermissionRequestListResponses, + V2PermissionRequestListErrors, + ThrowOnError + >({ + url: "/api/permission/request", + ...options, + ...params, + }) + } +} + +export class Saved extends HeyApiClient { + /** + * List saved permissions + * + * Retrieve saved permissions, optionally filtered by project. + */ + public list( + parameters?: { + projectID?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "projectID" }] }]) + return (options?.client ?? this.client).get< + V2PermissionSavedListResponses, + V2PermissionSavedListErrors, + ThrowOnError + >({ + url: "/api/permission/saved", + ...options, + ...params, + }) + } + + /** + * Remove saved permission + * + * Remove a saved permission by ID. + */ + public remove( + parameters: { + id: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "id" }] }]) + return (options?.client ?? this.client).delete< + V2PermissionSavedRemoveResponses, + V2PermissionSavedRemoveErrors, + ThrowOnError + >({ + url: "/api/permission/saved/{id}", + ...options, + ...params, + }) + } +} + +export class Permission3 extends HeyApiClient { + private _request?: Request + get request(): Request { + return (this._request ??= new Request({ client: this.client })) + } + + private _saved?: Saved + get saved(): Saved { + return (this._saved ??= new Saved({ client: this.client })) + } +} + +export class Fs extends HeyApiClient { + /** + * Read file + * + * Serve one file relative to the requested location. + */ + public read( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/fs/read/*", + ...options, + ...params, + }) + } + + /** + * List directory + * + * List direct children of one directory relative to the requested location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + path?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/fs/list", + ...options, + ...params, + }) + } + + /** + * Find files + * + * Find recursively ranked filesystem entries relative to the requested location. + */ + public find( + parameters: { + location?: { + directory?: string + workspace?: string + } + query: string + type?: "file" | "directory" + limit?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "query", key: "query" }, + { in: "query", key: "type" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/fs/find", + ...options, + ...params, + }) + } +} + +export class Command2 extends HeyApiClient { + /** + * List commands + * + * Retrieve currently registered commands. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/command", + ...options, + ...params, + }) + } +} + +export class Skill extends HeyApiClient { + /** + * List skills + * + * Retrieve currently registered skills. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/skill", + ...options, + ...params, + }) + } +} + +export class Event2 extends HeyApiClient { + /** + * Subscribe to events + * + * Subscribe to native event payloads for the server. + */ + public subscribe(options?: Options) { + return (options?.client ?? this.client).sse.get({ + url: "/api/event", + ...options, + }) + } +} + +export class Pty2 extends HeyApiClient { + /** + * List PTY sessions + * + * List PTY sessions for a location, including exited sessions retained until removal. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/pty", + ...options, + ...params, + }) + } + + /** + * Create PTY session + * + * Create a pseudo-terminal session for a location. + */ + public create( + parameters?: { + location?: { + directory?: string + workspace?: string + } + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "command" }, + { in: "body", key: "args" }, + { in: "body", key: "cwd" }, + { in: "body", key: "title" }, + { in: "body", key: "env" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/pty", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove PTY session + * + * Terminate and remove one PTY session. + */ + public remove( + parameters: { + ptyID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/api/pty/{ptyID}", + ...options, + ...params, + }) + } + + /** + * Get PTY session + * + * Get one PTY session, including its exit code once exited. + */ + public get( + parameters: { + ptyID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/pty/{ptyID}", + ...options, + ...params, + }) + } + + /** + * Update PTY session + * + * Update the title or viewport size of one PTY session. + */ + public update( + parameters: { + ptyID: string + location?: { + directory?: string + workspace?: string + } + title?: string + size?: { + rows: number + cols: number + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + { in: "body", key: "title" }, + { in: "body", key: "size" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/api/pty/{ptyID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Create PTY WebSocket token + * + * Create a short-lived single-use ticket for opening a PTY WebSocket connection. + */ + public connectToken( + parameters: { + ptyID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } + + /** + * Connect to PTY session + * + * Establish a WebSocket connection streaming PTY output and accepting terminal input. + */ + public connect( + parameters: { + ptyID: string + "location[directory]"?: string + "location[workspace]"?: string + cursor?: string + ticket?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location[directory]" }, + { in: "query", key: "location[workspace]" }, + { in: "query", key: "cursor" }, + { in: "query", key: "ticket" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/pty/{ptyID}/connect", + ...options, + ...params, + }) + } +} + +export class Request2 extends HeyApiClient { + /** + * List pending question requests + * + * Retrieve pending question requests for a location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2QuestionRequestListResponses, + V2QuestionRequestListErrors, + ThrowOnError + >({ + url: "/api/question/request", + ...options, + ...params, + }) + } +} + +export class Question3 extends HeyApiClient { + private _request?: Request2 + get request(): Request2 { + return (this._request ??= new Request2({ client: this.client })) + } +} + +export class Reference extends HeyApiClient { + /** + * List references + * + * List references available in the requested location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/reference", + ...options, + ...params, + }) + } +} + +export class ProjectCopy2 extends HeyApiClient { + public remove( + parameters: { + projectID: string + location?: { + directory?: string + workspace?: string + } + directory?: string + force?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "location" }, + { in: "body", key: "directory" }, + { in: "body", key: "force" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + V2ProjectCopyRemoveResponses, + V2ProjectCopyRemoveErrors, + ThrowOnError + >({ + url: "/experimental/project/{projectID}/copy", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + public create( + parameters: { + projectID: string + location?: { + directory?: string + workspace?: string + } + strategy?: string + directory?: string + name?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "location" }, + { in: "body", key: "strategy" }, + { in: "body", key: "directory" }, + { in: "body", key: "name" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post( + { + url: "/experimental/project/{projectID}/copy", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }, + ) + } + + public refresh( + parameters: { + projectID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2ProjectCopyRefreshResponses, + V2ProjectCopyRefreshErrors, + ThrowOnError + >({ + url: "/experimental/project/{projectID}/copy/refresh", + ...options, + ...params, + }) + } +} + +export class V2 extends HeyApiClient { + private _health?: Health + get health(): Health { + return (this._health ??= new Health({ client: this.client })) + } + + private _location?: Location + get location(): Location { + return (this._location ??= new Location({ client: this.client })) + } + + private _agent?: Agent + get agent(): Agent { + return (this._agent ??= new Agent({ client: this.client })) + } + + private _session?: Session3 + get session(): Session3 { + return (this._session ??= new Session3({ client: this.client })) + } + + private _model?: Model + get model(): Model { + return (this._model ??= new Model({ client: this.client })) + } + + private _provider?: Provider2 + get provider(): Provider2 { + return (this._provider ??= new Provider2({ client: this.client })) + } + + private _integration?: Integration + get integration(): Integration { + return (this._integration ??= new Integration({ client: this.client })) + } + + private _credential?: Credential + get credential(): Credential { + return (this._credential ??= new Credential({ client: this.client })) + } + + private _permission?: Permission3 + get permission(): Permission3 { + return (this._permission ??= new Permission3({ client: this.client })) + } + + private _fs?: Fs + get fs(): Fs { + return (this._fs ??= new Fs({ client: this.client })) + } + + private _command?: Command2 + get command(): Command2 { + return (this._command ??= new Command2({ client: this.client })) + } + + private _skill?: Skill + get skill(): Skill { + return (this._skill ??= new Skill({ client: this.client })) + } + + private _event?: Event2 + get event(): Event2 { + return (this._event ??= new Event2({ client: this.client })) + } + + private _pty?: Pty2 + get pty(): Pty2 { + return (this._pty ??= new Pty2({ client: this.client })) + } + + private _question?: Question3 + get question(): Question3 { + return (this._question ??= new Question3({ client: this.client })) + } + + private _reference?: Reference + get reference(): Reference { + return (this._reference ??= new Reference({ client: this.client })) + } + + private _projectCopy?: ProjectCopy2 + get projectCopy(): ProjectCopy2 { + return (this._projectCopy ??= new ProjectCopy2({ client: this.client })) + } +} + +export class OpencodeClient extends HeyApiClient { + public static readonly __registry = new HeyApiRegistry() + + constructor(args?: { client?: Client; key?: string }) { + super(args) + OpencodeClient.__registry.set(this, args?.key) + } + + private _auth?: Auth + get auth(): Auth { + return (this._auth ??= new Auth({ client: this.client })) + } + + private _app?: App + get app(): App { + return (this._app ??= new App({ client: this.client })) + } + + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + + private _global?: Global + get global(): Global { + return (this._global ??= new Global({ client: this.client })) + } + + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) + } + + private _config?: Config2 + get config(): Config2 { + return (this._config ??= new Config2({ client: this.client })) + } + + private _tool?: Tool + get tool(): Tool { + return (this._tool ??= new Tool({ client: this.client })) + } + + private _worktree?: Worktree + get worktree(): Worktree { + return (this._worktree ??= new Worktree({ client: this.client })) + } + + private _find?: Find + get find(): Find { + return (this._find ??= new Find({ client: this.client })) + } + + private _file?: File + get file(): File { + return (this._file ??= new File({ client: this.client })) + } + + private _instance?: Instance + get instance(): Instance { + return (this._instance ??= new Instance({ client: this.client })) + } + + private _path?: Path + get path(): Path { + return (this._path ??= new Path({ client: this.client })) + } + + private _vcs?: Vcs + get vcs(): Vcs { + return (this._vcs ??= new Vcs({ client: this.client })) + } + + private _command?: Command + get command(): Command { + return (this._command ??= new Command({ client: this.client })) + } + + private _lsp?: Lsp + get lsp(): Lsp { + return (this._lsp ??= new Lsp({ client: this.client })) + } + + private _formatter?: Formatter + get formatter(): Formatter { + return (this._formatter ??= new Formatter({ client: this.client })) + } + + private _mcp?: Mcp + get mcp(): Mcp { + return (this._mcp ??= new Mcp({ client: this.client })) + } + + private _project?: Project + get project(): Project { + return (this._project ??= new Project({ client: this.client })) + } + + private _pty?: Pty + get pty(): Pty { + return (this._pty ??= new Pty({ client: this.client })) + } + + private _question?: Question + get question(): Question { + return (this._question ??= new Question({ client: this.client })) + } + + private _permission?: Permission + get permission(): Permission { + return (this._permission ??= new Permission({ client: this.client })) + } + + private _provider?: Provider + get provider(): Provider { + return (this._provider ??= new Provider({ client: this.client })) + } + + private _session?: Session2 + get session(): Session2 { + return (this._session ??= new Session2({ client: this.client })) + } + + private _part?: Part + get part(): Part { + return (this._part ??= new Part({ client: this.client })) + } + + private _sync?: Sync + get sync(): Sync { + return (this._sync ??= new Sync({ client: this.client })) + } + + private _tui?: Tui + get tui(): Tui { + return (this._tui ??= new Tui({ client: this.client })) + } + + private _v2?: V2 + get v2(): V2 { + return (this._v2 ??= new V2({ client: this.client })) + } +} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..f06c20cc413e95c79f397a34d8d9d3e8871b6ab4 --- /dev/null +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -0,0 +1,13625 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}) +} + +export type Event = + | EventModelsDevRefreshed + | EventIntegrationUpdated + | EventIntegrationConnectionUpdated + | EventCatalogUpdated + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextMoved + | EventSessionNextPrompted + | EventSessionNextPromptAdmitted + | EventSessionNextContextUpdated + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextStepFailed + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolFailed + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventSessionNextRevertStaged + | EventSessionNextRevertCleared + | EventSessionNextRevertCommitted + | EventMessagePartDelta + | EventSessionDiff + | EventSessionError + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventFileEdited + | EventReferenceUpdated + | EventPermissionV2Asked + | EventPermissionV2Replied + | EventPluginAdded + | EventProjectDirectoriesUpdated + | EventFileWatcherUpdated + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventQuestionV2Asked + | EventQuestionV2Replied + | EventQuestionV2Rejected + | EventTodoUpdated + | EventLspUpdated + | EventPermissionAsked + | EventPermissionReplied + | EventTuiPromptAppend2 + | EventTuiCommandExecute2 + | EventTuiToastShow2 + | EventTuiSessionSelect2 + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventProjectUpdated + | EventSessionStatus + | EventSessionIdle + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventSessionCompacted + | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceStatus + | EventWorktreeReady + | EventWorktreeFailed + | EventServerConnected + | EventGlobalDisposed + | EventServerInstanceDisposed + +export type QuestionReplied = { + sessionID: string + requestID: string + answers: Array +} + +export type QuestionRejected = { + sessionID: string + requestID: string +} + +export type OAuth = { + type: "oauth" + refresh: string + access: string + expires: number + accountId?: string + enterpriseUrl?: string +} + +export type ApiAuth = { + type: "api" + key: string + metadata?: { + [key: string]: string + } +} + +export type WellKnownAuth = { + type: "wellknown" + key: string + token: string +} + +export type Auth = OAuth | ApiAuth | WellKnownAuth + +export type EffectHttpApiErrorBadRequest = { + _tag: "BadRequest" +} + +export type InvalidRequestError = { + _tag: "InvalidRequestError" + message: string + kind?: string + field?: string +} + +export type MoveSessionError = { + name: "MoveSessionError" + data: { + message: string + } +} + +export type SnapshotFileDiff = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +export type PermissionAction = "allow" | "deny" | "ask" + +export type PermissionRule = { + permission: string + pattern: string + action: PermissionAction +} + +export type PermissionRuleset = Array + +export type Session = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type OutputFormatText = { + type: "text" +} + +export type JsonSchema = { + [key: string]: unknown +} + +export type OutputFormatJsonSchema = { + type: "json_schema" + schema: JsonSchema + retryCount?: number +} + +export type OutputFormat = OutputFormatText | OutputFormatJsonSchema + +export type UserMessage = { + id: string + sessionID: string + role: "user" + time: { + created: number + } + format?: OutputFormat + summary?: { + title?: string + body?: string + diffs: Array + } + agent: string + model: { + providerID: string + modelID: string + variant?: string + } + system?: string + tools?: { + [key: string]: boolean + } +} + +export type ProviderAuthError = { + name: "ProviderAuthError" + data: { + providerID: string + message: string + } +} + +export type UnknownError = { + name: "UnknownError" + data: { + message: string + ref?: string + } +} + +export type MessageOutputLengthError = { + name: "MessageOutputLengthError" + data: { + [key: string]: unknown + } +} + +export type MessageAbortedError = { + name: "MessageAbortedError" + data: { + message: string + } +} + +export type StructuredOutputError = { + name: "StructuredOutputError" + data: { + message: string + retries: number + } +} + +export type ContextOverflowError = { + name: "ContextOverflowError" + data: { + message: string + responseBody?: string + } +} + +export type ContentFilterError = { + name: "ContentFilterError" + data: { + message: string + } +} + +export type ApiError = { + name: "APIError" + data: { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } + } +} + +export type AssistantMessage = { + id: string + sessionID: string + role: "assistant" + time: { + created: number + completed?: number + } + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + parentID: string + modelID: string + providerID: string + mode: string + agent: string + path: { + cwd: string + root: string + } + summary?: boolean + cost: number + tokens: { + total?: number + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + structured?: unknown + variant?: string + finish?: string +} + +export type Message = UserMessage | AssistantMessage + +export type TextPart = { + id: string + sessionID: string + messageID: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type SubtaskPart = { + id: string + sessionID: string + messageID: string + type: "subtask" + prompt: string + description: string + agent: string + model?: { + providerID: string + modelID: string + } + command?: string +} + +export type ReasoningPart = { + id: string + sessionID: string + messageID: string + type: "reasoning" + text: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + end?: number + } +} + +export type FilePartSourceText = { + value: string + start: number + end: number +} + +export type FileSource = { + text: FilePartSourceText + type: "file" + path: string +} + +export type Range = { + start: { + line: number + character: number + } + end: { + line: number + character: number + } +} + +export type SymbolSource = { + text: FilePartSourceText + type: "symbol" + path: string + range: Range + name: string + kind: number +} + +export type ResourceSource = { + text: FilePartSourceText + type: "resource" + clientName: string + uri: string +} + +export type FilePartSource = FileSource | SymbolSource | ResourceSource + +export type FilePart = { + id: string + sessionID: string + messageID: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type ToolStatePending = { + status: "pending" + input: { + [key: string]: unknown + } + raw: string +} + +export type ToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + title?: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + } +} + +export type ToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + output: string + title: string + metadata: { + [key: string]: unknown + } + time: { + start: number + end: number + compacted?: number + } + attachments?: Array +} + +export type ToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + error: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + end: number + } +} + +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export type ToolPart = { + id: string + sessionID: string + messageID: string + type: "tool" + callID: string + tool: string + state: ToolState + metadata?: { + [key: string]: unknown + } +} + +export type StepStartPart = { + id: string + sessionID: string + messageID: string + type: "step-start" + snapshot?: string +} + +export type StepFinishPart = { + id: string + sessionID: string + messageID: string + type: "step-finish" + reason: string + snapshot?: string + cost: number + tokens: { + total?: number + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } +} + +export type SnapshotPart = { + id: string + sessionID: string + messageID: string + type: "snapshot" + snapshot: string +} + +export type PatchPart = { + id: string + sessionID: string + messageID: string + type: "patch" + hash: string + files: Array +} + +export type AgentPart = { + id: string + sessionID: string + messageID: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type RetryPart = { + id: string + sessionID: string + messageID: string + type: "retry" + attempt: number + error: ApiError + time: { + created: number + } +} + +export type CompactionPart = { + id: string + sessionID: string + messageID: string + type: "compaction" + auto: boolean + overflow?: boolean + tail_start_id?: string +} + +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +export type Prompt = { + text: string + files?: Array + agents?: Array +} + +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number +} + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string +} + +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + action?: { + reason: string + provider: string + title: string + message: string + label: string + link?: string + } + next: number + } + | { + type: "busy" + } + +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string +} + +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + multiple?: boolean + custom?: boolean +} + +export type QuestionTool = { + messageID: string + callID: string +} + +export type QuestionAnswer = Array + +export type GlobalEvent = { + directory: string + project?: string + workspace?: string + payload: + | { + id: string + type: "models-dev.refreshed" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "integration.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "integration.connection.updated" + properties: { + integrationID: string + } + } + | { + id: string + type: "catalog.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } + } + | { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } + } + | { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } + } + | { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } + } + | { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + messageID: string + agent: string + } + } + | { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } + } + | { + id: string + type: "session.next.moved" + properties: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } + } + | { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } + | { + id: string + type: "session.next.prompt.admitted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } + | { + id: string + type: "session.next.context.updated" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } + | { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } + | { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } + } + | { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } + } + | { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } + } + | { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } + } + | { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } + } + | { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } + } + | { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + delta: string + } + } + | { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } + } + | { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } + } + | { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + delta: string + } + } + | { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } + } + | { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } + } + | { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + delta: string + } + } + | { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } + } + | { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } + | { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } + } + | { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } + | { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } + | { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } + } + | { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } + } + | { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } + | { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + } + } + | { + id: string + type: "session.next.revert.staged" + properties: { + timestamp: number + sessionID: string + revert: RevertState + } + } + | { + id: string + type: "session.next.revert.cleared" + properties: { + timestamp: number + sessionID: string + } + } + | { + id: string + type: "session.next.revert.committed" + properties: { + timestamp: number + sessionID: string + messageID: string + } + } + | { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } + } + | { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } + } + | { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + } + } + | { + id: string + type: "installation.updated" + properties: { + version: string + } + } + | { + id: string + type: "installation.update-available" + properties: { + version: string + } + } + | { + id: string + type: "file.edited" + properties: { + file: string + } + } + | { + id: string + type: "reference.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "permission.v2.asked" + properties: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } + } + | { + id: string + type: "permission.v2.replied" + properties: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } + } + | { + id: string + type: "plugin.added" + properties: { + id: string + } + } + | { + id: string + type: "project.directories.updated" + properties: { + projectID: string + } + } + | { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } + } + | { + id: string + type: "pty.created" + properties: { + info: Pty + } + } + | { + id: string + type: "pty.updated" + properties: { + info: Pty + } + } + | { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } + } + | { + id: string + type: "pty.deleted" + properties: { + id: string + } + } + | { + id: string + type: "question.v2.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } + } + | { + id: string + type: "question.v2.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } + } + | { + id: string + type: "question.v2.rejected" + properties: { + sessionID: string + requestID: string + } + } + | { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } + } + | { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "permission.asked" + properties: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } + } + | { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } + } + | { + id: string + type: "tui.prompt.append" + properties: { + text: string + } + } + | { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } + } + | { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } + } + | { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } + } + | { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } + } + | { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } + } + | { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } + } + | { + id: string + type: "project.updated" + properties: { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } + } + | { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } + } + | { + id: string + type: "session.idle" + properties: { + sessionID: string + } + } + | { + id: string + type: "question.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool + } + } + | { + id: string + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } + } + | { + id: string + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } + } + | { + id: string + type: "session.compacted" + properties: { + sessionID: string + } + } + | { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } + } + | { + id: string + type: "workspace.ready" + properties: { + name: string + } + } + | { + id: string + type: "workspace.failed" + properties: { + message: string + } + } + | { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } + } + | { + id: string + type: "worktree.ready" + properties: { + name: string + branch?: string + } + } + | { + id: string + type: "worktree.failed" + properties: { + message: string + } + } + | { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } + } + | EventServerInstanceDisposed + | SyncEventSessionCreated + | SyncEventSessionUpdated + | SyncEventSessionDeleted + | SyncEventMessageUpdated + | SyncEventMessageRemoved + | SyncEventMessagePartUpdated + | SyncEventMessagePartRemoved + | SyncEventSessionNextAgentSwitched + | SyncEventSessionNextModelSwitched + | SyncEventSessionNextMoved + | SyncEventSessionNextPrompted + | SyncEventSessionNextPromptAdmitted + | SyncEventSessionNextContextUpdated + | SyncEventSessionNextSynthetic + | SyncEventSessionNextShellStarted + | SyncEventSessionNextShellEnded + | SyncEventSessionNextStepStarted + | SyncEventSessionNextStepEnded + | SyncEventSessionNextStepFailed + | SyncEventSessionNextTextStarted + | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningEnded + | SyncEventSessionNextToolInputStarted + | SyncEventSessionNextToolInputEnded + | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress + | SyncEventSessionNextToolSuccess + | SyncEventSessionNextToolFailed + | SyncEventSessionNextRetried + | SyncEventSessionNextCompactionStarted + | SyncEventSessionNextCompactionEnded + | SyncEventSessionNextRevertStaged + | SyncEventSessionNextRevertCleared + | SyncEventSessionNextRevertCommitted +} + +/** + * Log level + */ +export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" + +/** + * Server configuration for opencode serve and web commands + */ +export type ServerConfig = { + port?: number + hostname?: string + mdns?: boolean + mdnsDomain?: string + cors?: Array +} + +export type PermissionActionConfig = "ask" | "allow" | "deny" + +export type PermissionObjectConfig = { + [key: string]: PermissionActionConfig +} + +export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig + +export type PermissionConfig = + | PermissionActionConfig + | { + read?: PermissionRuleConfig + edit?: PermissionRuleConfig + glob?: PermissionRuleConfig + grep?: PermissionRuleConfig + list?: PermissionRuleConfig + bash?: PermissionRuleConfig + task?: PermissionRuleConfig + external_directory?: PermissionRuleConfig + todowrite?: PermissionActionConfig + question?: PermissionActionConfig + webfetch?: PermissionActionConfig + websearch?: PermissionActionConfig + lsp?: PermissionRuleConfig + doom_loop?: PermissionActionConfig + skill?: PermissionRuleConfig + [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined + } + +export type AgentConfig = { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + /** + * Hex color code (e.g., #FF5733) or theme color (e.g., primary) + */ + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: PermissionConfig + [key: string]: + | unknown + | string + | number + | { + [key: string]: boolean + } + | boolean + | "subagent" + | "primary" + | "all" + | { + [key: string]: unknown + } + | string + | "primary" + | "secondary" + | "accent" + | "success" + | "warning" + | "error" + | "info" + | number + | PermissionConfig + | undefined +} + +export type ProviderConfig = { + api?: string + name?: string + env?: Array + id?: string + npm?: string + whitelist?: Array + blacklist?: Array + options?: { + apiKey?: string + baseURL?: string + enterpriseUrl?: string + setCacheKey?: boolean + /** + * Timeout in milliseconds for full requests to this provider. Set to false to disable timeout. + */ + timeout?: number | false + /** + * Timeout in milliseconds to wait for response headers (default: 300000). Set to false to disable timeout. + */ + headerTimeout?: number | false + /** + * Timeout in milliseconds between streamed SSE chunks for this provider (default: 300000). If no chunk arrives within this window, the request is aborted. Set to false to disable timeout. + */ + chunkTimeout?: number | false + [key: string]: unknown | string | boolean | number | false | number | false | number | false | undefined + } + models?: { + [key: string]: { + id?: string + name?: string + family?: string + release_date?: string + attachment?: boolean + reasoning?: boolean + temperature?: boolean + tool_call?: boolean + interleaved?: + | boolean + | "reasoning" + | "reasoning_content" + | "reasoning_text" + | string + | { + field: "reasoning" | "reasoning_content" | "reasoning_text" | string + } + cost?: { + input: number + output: number + cache_read?: number + cache_write?: number + context_over_200k?: { + input: number + output: number + cache_read?: number + cache_write?: number + } + } + limit?: { + context: number + input?: number + output: number + } + modalities?: { + input?: Array<"text" | "audio" | "image" | "video" | "pdf"> + output?: Array<"text" | "audio" | "image" | "video" | "pdf"> + } + experimental?: boolean + status?: "alpha" | "beta" | "deprecated" | "active" + provider?: { + npm?: string + api?: string + } + options?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + /** + * Variant-specific configuration + */ + variants?: { + [key: string]: { + disabled?: boolean + [key: string]: unknown | boolean | undefined + } + } + } + } +} + +export type McpLocalConfig = { + /** + * Type of MCP server connection + */ + type: "local" + /** + * Command and arguments to run the MCP server + */ + command: Array + cwd?: string + environment?: { + [key: string]: string + } + enabled?: boolean + timeout?: number +} + +export type McpOAuthConfig = { + clientId?: string + clientSecret?: string + scope?: string + callbackPort?: number + redirectUri?: string +} + +export type McpRemoteConfig = { + /** + * Type of MCP server connection + */ + type: "remote" + /** + * URL of the remote MCP server + */ + url: string + enabled?: boolean + headers?: { + [key: string]: string + } + /** + * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. + */ + oauth?: McpOAuthConfig | false + timeout?: number +} + +/** + * @deprecated Always uses stretch layout. + */ +export type LayoutConfig = "auto" | "stretch" + +export type ImageAttachmentConfig = { + auto_resize?: boolean + max_width?: number + max_height?: number + max_base64_bytes?: number +} + +export type AttachmentConfig = { + image?: ImageAttachmentConfig +} + +export type Config = { + $schema?: string + shell?: string + logLevel?: LogLevel + server?: ServerConfig + command?: { + [key: string]: { + template: string + description?: string + agent?: string + model?: string + variant?: string + subtask?: boolean + } + } + skills?: { + paths?: Array + urls?: Array + } + references?: { + [key: string]: string | ConfigV2ReferenceGit | ConfigV2ReferenceLocal + } + reference?: { + [key: string]: string | ConfigV2ReferenceGit | ConfigV2ReferenceLocal + } + watcher?: { + ignore?: Array + } + snapshot?: boolean + plugin?: Array< + | string + | [ + string, + { + [key: string]: unknown + }, + ] + > + share?: "manual" | "auto" | "disabled" + autoshare?: boolean + /** + * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications + */ + autoupdate?: boolean | "notify" + disabled_providers?: Array + enabled_providers?: Array + model?: string + small_model?: string + default_agent?: string + subagent_depth?: number + username?: string + mode?: { + build?: AgentConfig + plan?: AgentConfig + [key: string]: AgentConfig | undefined + } + agent?: { + plan?: AgentConfig + build?: AgentConfig + general?: AgentConfig + explore?: AgentConfig + title?: AgentConfig + summary?: AgentConfig + compaction?: AgentConfig + [key: string]: AgentConfig | undefined + } + provider?: { + [key: string]: ProviderConfig + } + mcp?: { + [key: string]: + | McpLocalConfig + | McpRemoteConfig + | { + enabled: boolean + } + } + /** + * Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ + formatter?: + | boolean + | { + [key: string]: { + disabled?: boolean + command?: Array + environment?: { + [key: string]: string + } + extensions?: Array + } + } + /** + * Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ + lsp?: + | boolean + | { + [key: string]: + | { + disabled: true + } + | { + command: Array + extensions?: Array + disabled?: boolean + env?: { + [key: string]: string + } + initialization?: { + [key: string]: unknown + } + } + } + instructions?: Array + layout?: LayoutConfig + permission?: PermissionConfig + tools?: { + [key: string]: boolean + } + attachment?: AttachmentConfig + enterprise?: { + url?: string + } + tool_output?: { + max_lines?: number + max_bytes?: number + } + compaction?: { + auto?: boolean + prune?: boolean + tail_turns?: number + preserve_recent_tokens?: number + reserved?: number + } + experimental?: { + disable_paste_summary?: boolean + batch_tool?: boolean + openTelemetry?: boolean + primary_tools?: Array + continue_loop_on_deny?: boolean + mcp_timeout?: number + policies?: Array + } +} + +export type Model = { + id: string + providerID: string + api: { + id: string + url: string + npm: string + } + name: string + family?: string + capabilities: { + temperature: boolean + reasoning: boolean + attachment: boolean + toolcall: boolean + input: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + output: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + interleaved: + | boolean + | { + field: "reasoning" | "reasoning_content" | "reasoning_text" | string + } + } + cost: { + input: number + output: number + cache: { + read: number + write: number + } + tiers?: Array<{ + input: number + output: number + cache: { + read: number + write: number + } + tier: { + type: "context" + size: number + } + }> + experimentalOver200K?: { + input: number + output: number + cache: { + read: number + write: number + } + } + } + limit: { + context: number + input?: number + output: number + } + status: "alpha" | "beta" | "deprecated" | "active" + options: { + [key: string]: unknown + } + headers: { + [key: string]: string + } + release_date: string + variants?: { + [key: string]: { + [key: string]: unknown + } + } +} + +export type Provider = { + id: string + name: string + source: "env" | "config" | "custom" | "api" + env: Array + key?: string + options: { + [key: string]: unknown + } + models: { + [key: string]: Model + } +} + +export type ExperimentalCapabilities = { + backgroundSubagents: boolean +} + +export type ConsoleState = { + consoleManagedProviders: Array + activeOrgName?: string + switchableOrgCount: number +} + +export type EffectHttpApiErrorInternalServerError = { + _tag: "InternalServerError" +} + +export type ToolListItem = { + id: string + description: string + parameters: unknown +} + +export type ToolList = Array + +export type ToolIds = Array + +export type WorktreeError = { + name: + | "WorktreeNotGitError" + | "WorktreeNameGenerationFailedError" + | "WorktreeCreateFailedError" + | "WorktreeStartCommandFailedError" + | "WorktreeRemoveFailedError" + | "WorktreeResetFailedError" + | "WorktreeListFailedError" + data: { + message: string + } +} + +export type WorktreeCreateInput = { + name?: string + /** + * Additional startup script to run after the project's start command + */ + startCommand?: string +} + +export type Worktree = { + name: string + branch?: string + directory: string +} + +export type WorktreeRemoveInput = { + directory: string +} + +export type WorktreeResetInput = { + directory: string +} + +export type ProjectSummary = { + id: string + name?: string + worktree: string +} + +export type GlobalSession = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } + project: ProjectSummary | null +} + +export type McpResource = { + name: string + uri: string + description?: string + mimeType?: string + client: string +} + +export type Symbol = { + name: string + kind: number + location: { + uri: string + range: Range + } +} + +export type FileNode = { + name: string + path: string + absolute: string + type: "file" | "directory" + ignored: boolean +} + +export type FileContent = { + type: "text" | "binary" + content: string + diff?: string + patch?: { + oldFileName: string + newFileName: string + oldHeader?: string + newHeader?: string + hunks: Array<{ + oldStart: number + oldLines: number + newStart: number + newLines: number + lines: Array + }> + index?: string + } + encoding?: "base64" + mimeType?: string +} + +export type File = { + path: string + added: number + removed: number + status: "added" | "deleted" | "modified" +} + +export type Path = { + home: string + state: string + config: string + worktree: string + directory: string +} + +export type VcsInfo = { + branch?: string + default_branch?: string +} + +export type VcsFileStatus = { + file: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + +export type VcsFileDiff = { + file: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +export type VcsApplyError = { + name: "VcsApplyError" + data: { + message: string + reason: "non-git" | "not-clean" + } +} + +export type Command = { + name: string + description?: string + agent?: string + model?: string + source?: "command" | "mcp" | "skill" + template: string + subtask?: boolean + hints: Array +} + +export type Agent = { + name: string + description?: string + mode: "subagent" | "primary" | "all" + native?: boolean + hidden?: boolean + topP?: number + temperature?: number + color?: string + permission: PermissionRuleset + model?: { + modelID: string + providerID: string + } + variant?: string + prompt?: string + options: { + [key: string]: unknown + } + steps?: number +} + +export type LspStatus = { + id: string + name: string + root: string + status: "connected" | "error" +} + +export type FormatterStatus = { + name: string + extensions: Array + enabled: boolean +} + +export type McpStatusConnected = { + status: "connected" +} + +export type McpStatusDisabled = { + status: "disabled" +} + +export type McpStatusFailed = { + status: "failed" + error: string +} + +export type McpStatusNeedsAuth = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration" + error: string +} + +export type McpStatus = + | McpStatusConnected + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration + +export type McpUnsupportedOAuthError = { + error: string +} + +export type McpServerNotFoundError = { + _tag: "McpServerNotFoundError" + name: string + message: string +} + +export type Project = { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array +} + +export type ProjectNotFoundError = { + _tag: "ProjectNotFoundError" + projectID: string + message: string +} + +export type PtyNotFoundError = { + _tag: "PtyNotFoundError" + ptyID: string + message: string +} + +export type PtyForbiddenError = { + _tag: "PtyForbiddenError" + message: string +} + +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool +} + +export type QuestionNotFoundError = { + _tag: "QuestionNotFoundError" + requestID: string + message: string +} + +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } +} + +export type PermissionNotFoundError = { + _tag: "PermissionNotFoundError" + requestID: string + message: string +} + +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + > +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export type ProviderAuthError1 = { + name: + | "BadRequest" + | "ProviderAuthOauthMissing" + | "ProviderAuthOauthCodeMissing" + | "ProviderAuthOauthCallbackFailed" + | "ProviderAuthValidationFailed" + data: { + providerID?: string + field?: string + message?: string + kind?: string + } +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string + model?: { + providerID: string + modelID: string + } + command?: string +} + +export type SessionBusyError = { + _tag: "SessionBusyError" + sessionID: string + message: string +} + +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type Workspace = { + id: string + type: string + name: string + branch?: string | null + directory?: string | null + extra?: unknown | null + projectID: string + timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" +} + +export type WorkspaceCreateError = { + name: "WorkspaceCreateError" + data: { + message: string + } +} + +export type WorkspaceWarpError = { + name: "WorkspaceWarpError" + data: { + message: string + } +} + +export type UnauthorizedError = { + _tag: "UnauthorizedError" + message: string +} + +export type SessionsResponse = { + data: Array + cursor: { + previous?: string + next?: string + } +} + +export type InvalidCursorError = { + _tag: "InvalidCursorError" + message: string +} + +export type SessionActive = { + type: "running" +} + +export type SessionNotFoundError = { + _tag: "SessionNotFoundError" + sessionID: string + message: string +} + +export type PromptInput = { + text: string + files?: Array + agents?: Array +} + +export type ConflictError = { + _tag: "ConflictError" + message: string + resource?: string +} + +export type ServiceUnavailableError = { + _tag: "ServiceUnavailableError" + message: string + service?: string +} + +export type MessageNotFoundError = { + _tag: "MessageNotFoundError" + sessionID: string + messageID: string + message: string +} + +export type UnknownError1 = { + _tag: "UnknownError" + message: string + ref?: string +} + +export type SessionDurableEvent = + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextEnded + | SessionNextToolInputStarted + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextReasoningStarted + | SessionNextReasoningEnded + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + +export type SessionHistory = { + data: Array + hasMore: boolean +} + +export type SessionDurableEventStream = string + +export type SessionMessagesResponse = { + data: Array + cursor: { + previous?: string + next?: string + } +} + +export type ProviderNotFoundError = { + _tag: "ProviderNotFoundError" + providerID: string + message: string +} + +export type OutputFormat1 = + | { + type: "text" + } + | { + type: "json_schema" + schema: JsonSchema + retryCount?: number + } + +export type SessionStatus2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + status: SessionStatus + } +} + +export type QuestionReplied2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionRejected2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type V2Event = + | ModelsDevRefreshed + | IntegrationUpdated + | IntegrationConnectionUpdated + | CatalogUpdated + | SessionCreated + | SessionUpdated + | SessionDeleted + | MessageUpdated + | MessageRemoved + | MessagePartUpdated + | MessagePartRemoved + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextDelta + | SessionNextTextEnded + | SessionNextReasoningStarted + | SessionNextReasoningDelta + | SessionNextReasoningEnded + | SessionNextToolInputStarted + | SessionNextToolInputDelta + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionDelta + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + | MessagePartDelta + | SessionDiff + | SessionError + | InstallationUpdated + | InstallationUpdateAvailable + | FileEdited + | ReferenceUpdated + | PermissionV2Asked + | PermissionV2Replied + | PluginAdded + | ProjectDirectoriesUpdated + | FileWatcherUpdated + | PtyCreated + | PtyUpdated + | PtyExited + | PtyDeleted + | QuestionV2Asked + | QuestionV2Replied + | QuestionV2Rejected + | TodoUpdated + | LspUpdated + | PermissionAsked + | PermissionReplied + | TuiPromptAppend + | TuiCommandExecute + | TuiToastShow + | TuiSessionSelect + | McpToolsChanged + | McpBrowserOpenFailed + | CommandExecuted + | ProjectUpdated + | SessionStatus2 + | SessionIdle + | QuestionAsked + | QuestionReplied2 + | QuestionRejected2 + | SessionCompacted + | VcsBranchUpdated + | WorkspaceReady + | WorkspaceFailed + | WorkspaceStatus + | WorktreeReady + | WorktreeFailed + | ServerConnected + | GlobalDisposed + +export type V2EventStream = string + +export type ForbiddenError = { + _tag: "ForbiddenError" + message: string +} + +export type ProjectCopyError = { + name: "ProjectCopyError" + data: { + message: string + forceRequired?: boolean + } +} + +export type EffectHttpApiErrorForbidden = { + _tag: "Forbidden" +} + +export type EventTuiPromptAppend2 = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute2 = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow2 = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect2 = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type CredentialValue = CredentialOAuth | CredentialKey + +export type IntegrationInputs = { + [key: string]: string +} + +export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod + +export type IntegrationRef = { + id: string + name: string +} + +export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2EmbeddedSource + +export type MoveSessionDestination = { + directory: string +} + +export type ModelRef = { + id: string + providerID: string + variant?: string +} + +export type LocationRef = { + directory: string + workspaceID?: string +} + +export type PromptSource = { + start: number + end: number + text: string +} + +export type PromptFileAttachment = { + uri: string + mime: string + name?: string + description?: string + source?: PromptSource +} + +export type PromptAgentAttachment = { + name: string + source?: PromptSource +} + +export type SessionErrorUnknown = { + type: "unknown" + message: string +} + +export type LlmProviderMetadata = { + [key: string]: { + [key: string]: unknown + } +} + +export type ToolTextContent = { + type: "text" + text: string +} + +export type ToolFileContent = { + type: "file" + uri: string + mime: string + name?: string +} + +export type LlmToolContent = ToolTextContent | ToolFileContent + +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } +} + +export type FileDiff = { + path: string + status: "added" | "modified" | "deleted" + additions: number + deletions: number + patch: string +} + +export type RevertState = { + messageID: string + partID?: string + snapshot?: string + diff?: string + files?: Array +} + +export type PermissionV2Source = { + type: "tool" + messageID: string + callID: string +} + +export type PermissionV2Reply = "once" | "always" | "reject" + +export type QuestionV2Option = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string +} + +export type QuestionV2Info = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + multiple?: boolean + custom?: boolean +} + +export type QuestionV2Tool = { + messageID: string + callID: string +} + +export type QuestionV2Answer = Array + +export type ProjectVcs = "git" + +export type ProjectIcon = { + url?: string + override?: string + color?: string +} + +export type ProjectCommands = { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string +} + +export type ProjectTime = { + created: number + updated: number + initialized?: number +} + +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + +export type SyncEventSessionCreated = { + type: "sync" + id: string + syncEvent: { + type: "session.created.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + info: Session + } + } +} + +export type SyncEventSessionUpdated = { + type: "sync" + id: string + syncEvent: { + type: "session.updated.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + info: Session + } + } +} + +export type SyncEventSessionDeleted = { + type: "sync" + id: string + syncEvent: { + type: "session.deleted.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + info: Session + } + } +} + +export type SyncEventMessageUpdated = { + type: "sync" + id: string + syncEvent: { + type: "message.updated.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + info: Message + } + } +} + +export type SyncEventMessageRemoved = { + type: "sync" + id: string + syncEvent: { + type: "message.removed.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + messageID: string + } + } +} + +export type SyncEventMessagePartUpdated = { + type: "sync" + id: string + syncEvent: { + type: "message.part.updated.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + part: Part + time: number + } + } +} + +export type SyncEventMessagePartRemoved = { + type: "sync" + id: string + syncEvent: { + type: "message.part.removed.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + messageID: string + partID: string + } + } +} + +export type SyncEventSessionNextAgentSwitched = { + type: "sync" + id: string + syncEvent: { + type: "session.next.agent.switched.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + agent: string + } + } +} + +export type SyncEventSessionNextModelSwitched = { + type: "sync" + id: string + syncEvent: { + type: "session.next.model.switched.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } + } +} + +export type SyncEventSessionNextMoved = { + type: "sync" + id: string + syncEvent: { + type: "session.next.moved.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } + } +} + +export type SyncEventSessionNextPrompted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.prompted.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } +} + +export type SyncEventSessionNextPromptAdmitted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.prompt.admitted.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } +} + +export type SyncEventSessionNextContextUpdated = { + type: "sync" + id: string + syncEvent: { + type: "session.next.context.updated.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } +} + +export type SyncEventSessionNextSynthetic = { + type: "sync" + id: string + syncEvent: { + type: "session.next.synthetic.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } +} + +export type SyncEventSessionNextShellStarted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.shell.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } + } +} + +export type SyncEventSessionNextShellEnded = { + type: "sync" + id: string + syncEvent: { + type: "session.next.shell.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + output: string + } + } +} + +export type SyncEventSessionNextStepStarted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.step.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } + } +} + +export type SyncEventSessionNextStepEnded = { + type: "sync" + id: string + syncEvent: { + type: "session.next.step.ended.2" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } + } +} + +export type SyncEventSessionNextStepFailed = { + type: "sync" + id: string + syncEvent: { + type: "session.next.step.failed.2" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } + } +} + +export type SyncEventSessionNextTextStarted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.text.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } + } +} + +export type SyncEventSessionNextTextEnded = { + type: "sync" + id: string + syncEvent: { + type: "session.next.text.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } + } +} + +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + id: string + syncEvent: { + type: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } + } +} + +export type SyncEventSessionNextToolInputStarted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } + } +} + +export type SyncEventSessionNextToolInputEnded = { + type: "sync" + id: string + syncEvent: { + type: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } + } +} + +export type SyncEventSessionNextToolCalled = { + type: "sync" + id: string + syncEvent: { + type: "session.next.tool.called.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } +} + +export type SyncEventSessionNextToolProgress = { + type: "sync" + id: string + syncEvent: { + type: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } + } +} + +export type SyncEventSessionNextToolSuccess = { + type: "sync" + id: string + syncEvent: { + type: "session.next.tool.success.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } +} + +export type SyncEventSessionNextToolFailed = { + type: "sync" + id: string + syncEvent: { + type: "session.next.tool.failed.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } +} + +export type SyncEventSessionNextRetried = { + type: "sync" + id: string + syncEvent: { + type: "session.next.retried.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } + } +} + +export type SyncEventSessionNextCompactionStarted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.compaction.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } + } +} + +export type SyncEventSessionNextCompactionEnded = { + type: "sync" + id: string + syncEvent: { + type: "session.next.compaction.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + } + } +} + +export type SyncEventSessionNextRevertStaged = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.staged.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + revert: RevertState + } + } +} + +export type SyncEventSessionNextRevertCleared = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.cleared.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + } + } +} + +export type SyncEventSessionNextRevertCommitted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.committed.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + } + } +} + +export type ConfigV2ReferenceGit = { + repository: string + branch?: string + description?: string + hidden?: boolean +} + +export type ConfigV2ReferenceLocal = { + path: string + description?: string + hidden?: boolean +} + +export type PolicyEffect = "allow" | "deny" + +export type ConfigV2ExperimentalPolicy = { + action: "provider.use" + effect: PolicyEffect + resource: string +} + +export type ProjectDirectories = Array<{ + directory: string + strategy?: string +}> + +export type PtyTicketConnectToken = { + ticket: string + expires_in: number +} + +export type WorkspaceEventConnectionStatus = { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" +} + +export type LocationInfo = { + directory: string + workspaceID?: string + project: { + id: string + directory: string + } +} + +export type ProviderRequest = { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } +} + +export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + +export type PermissionV2Effect = "allow" | "deny" | "ask" + +export type PermissionV2Rule = { + action: string + resource: string + effect: PermissionV2Effect +} + +export type PermissionV2Ruleset = Array + +export type AgentV2Info = { + id: string + model?: ModelRef + request: ProviderRequest + system?: string + description?: string + mode: "subagent" | "primary" | "all" + hidden: boolean + color?: AgentColor + steps?: number + permissions: PermissionV2Ruleset +} + +export type SessionV2Info = { + id: string + parentID?: string + projectID: string + agent?: string + model?: ModelRef + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + time: { + created: number + updated: number + archived?: number + } + title: string + location: LocationRef + subpath?: string + revert?: RevertState +} + +export type PromptInputFileAttachment = { + uri: string + name?: string + description?: string + source?: PromptSource +} + +export type SessionInputAdmitted = { + admittedSeq: number + id: string + sessionID: string + prompt: Prompt + delivery: "steer" | "queue" + timeCreated: number + promotedSeq?: number +} + +export type SessionMessageAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "agent-switched" + agent: string +} + +export type SessionMessageModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: ModelRef +} + +export type SessionMessageUser = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionMessageSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + sessionID: string + text: string + type: "synthetic" +} + +export type SessionMessageSystem = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "system" + text: string +} + +export type SessionMessageShell = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + callID: string + command: string + output: string +} + +export type SessionMessageAssistantText = { + type: "text" + id: string + text: string +} + +export type SessionMessageAssistantReasoning = { + type: "reasoning" + id: string + text: string + providerMetadata?: LlmProviderMetadata + time?: { + created: number + completed?: number + } +} + +export type SessionMessageToolStatePending = { + status: "pending" + input: string +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + structured: { + [key: string]: unknown + } + content: Array +} + +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + attachments?: Array + content: Array + outputPaths?: Array + structured: { + [key: string]: unknown + } + result?: unknown +} + +export type SessionMessageToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + content: Array + structured: { + [key: string]: unknown + } + error: SessionErrorUnknown + result?: unknown +} + +export type SessionMessageAssistantTool = { + type: "tool" + id: string + name: string + provider?: { + executed: boolean + metadata?: LlmProviderMetadata + resultMetadata?: LlmProviderMetadata + } + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number + } +} + +export type SessionMessageAssistant = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: ModelRef + content: Array + snapshot?: { + start?: string + end?: string + files?: Array + } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: SessionErrorUnknown +} + +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + recent: string + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } +} + +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageSystem + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type SessionNextAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.agent.switched" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + agent: string + } +} + +export type SessionNextModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.model.switched" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } +} + +export type SessionNextMoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.moved" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } +} + +export type SessionNextPrompted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextPromptAdmitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompt.admitted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextContextUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.context.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.synthetic" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextShellStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } +} + +export type SessionNextShellEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SessionNextStepStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type SessionNextStepEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } +} + +export type SessionNextStepFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } +} + +export type SessionNextTextStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } +} + +export type SessionNextTextEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } +} + +export type SessionNextToolInputStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type SessionNextToolInputEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type SessionNextToolCalled = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.called" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolProgress = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.progress" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SessionNextToolSuccess = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.success" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextReasoningStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionNextReasoningEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionNextRetried = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.retried" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SessionNextCompactionStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } +} + +export type SessionNextCompactionEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + } +} + +export type SessionNextRevertStaged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.staged" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + revert: RevertState + } +} + +export type SessionNextRevertCleared = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.cleared" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + } +} + +export type SessionNextRevertCommitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.committed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + } +} + +export type ModelApi = + | { + id: string + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } + } + | { + id: string + type: "native" + url?: string + settings: { + [key: string]: unknown + } + } + +export type ModelCapabilities = { + tools: boolean + input: Array + output: Array +} + +export type ModelCost = { + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } +} + +export type ModelV2Info = { + id: string + providerID: string + family?: string + name: string + api: ModelApi + capabilities: ModelCapabilities + request: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + }> + time: { + released: number + } + cost: Array + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number + } +} + +export type ProviderAisdk = { + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } +} + +export type ProviderNative = { + type: "native" + url?: string + settings: { + [key: string]: unknown + } +} + +export type ProviderApi = ProviderAisdk | ProviderNative + +export type ProviderV2Info = { + id: string + integrationID?: string + name: string + disabled?: boolean + api: ProviderApi + request: ProviderRequest +} + +export type IntegrationWhen = { + key: string + op: "eq" | "neq" + value: string +} + +export type IntegrationTextPrompt = { + type: "text" + key: string + message: string + placeholder?: string + when?: IntegrationWhen +} + +export type IntegrationSelectPrompt = { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: IntegrationWhen +} + +export type IntegrationOAuthMethod = { + id: string + type: "oauth" + label: string + prompts?: Array +} + +export type IntegrationKeyMethod = { + type: "key" + label?: string +} + +export type IntegrationEnvMethod = { + type: "env" + names: Array +} + +export type ConnectionCredentialInfo = { + type: "credential" + id: string + label: string +} + +export type ConnectionEnvInfo = { + type: "env" + name: string +} + +export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo + +export type IntegrationInfo = { + id: string + name: string + methods: Array + connections: Array +} + +export type IntegrationAttempt = { + attemptID: string + url: string + instructions: string + mode: "auto" | "code" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + +export type IntegrationAttemptStatus = + | { + status: "pending" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "complete" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "failed" + message: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "expired" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + +export type PermissionV2Request = { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source +} + +export type PermissionSavedInfo = { + id: string + projectID: string + action: string + resource: string +} + +export type FileSystemEntry = { + path: string + type: "file" | "directory" +} + +export type CommandV2Info = { + name: string + template: string + description?: string + agent?: string + model?: ModelRef + subtask?: boolean +} + +export type SkillV2Info = { + name: string + description?: string + slash?: boolean + location: string + content: string +} + +export type ModelsDevRefreshed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "models-dev.refreshed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type IntegrationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "integration.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type IntegrationConnectionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "integration.connection.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + integrationID: string + } +} + +export type CatalogUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "catalog.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type SessionCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type SessionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type SessionDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type MessageUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Message + } +} + +export type MessageRemoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.removed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + } +} + +export type MessagePartUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + part: Part + time: number + } +} + +export type MessagePartRemoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.removed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SessionNextTextDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + delta: string + } +} + +export type SessionNextReasoningDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + delta: string + } +} + +export type SessionNextToolInputDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + delta: string + } +} + +export type SessionNextCompactionDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type MessagePartDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type SessionDiff = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.diff" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + diff: Array + } +} + +export type SessionError = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.error" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + } +} + +export type InstallationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "installation.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + version: string + } +} + +export type InstallationUpdateAvailable = { + id: string + metadata?: { + [key: string]: unknown + } + type: "installation.update-available" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + version: string + } +} + +export type FileEdited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "file.edited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + file: string + } +} + +export type ReferenceUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "reference.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type PermissionV2Asked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.v2.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } +} + +export type PermissionV2Replied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.v2.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + +export type PluginAdded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "plugin.added" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + +export type ProjectDirectoriesUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "project.directories.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + projectID: string + } +} + +export type FileWatcherUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "file.watcher.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type PtyCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Pty + } +} + +export type PtyUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Pty + } +} + +export type PtyExited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.exited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + exitCode: number + } +} + +export type PtyDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + +export type QuestionV2Asked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } +} + +export type QuestionV2Replied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionV2Rejected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type TodoUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "todo.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + todos: Array + } +} + +export type LspUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "lsp.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type PermissionAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } +} + +export type PermissionReplied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type TuiPromptAppend = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.prompt.append" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + text: string + } +} + +export type TuiCommandExecute = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.command.execute" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type TuiToastShow = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.toast.show" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type TuiSessionSelect = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.session.select" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type McpToolsChanged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.tools.changed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + server: string + } +} + +export type McpBrowserOpenFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.browser.open.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + mcpName: string + url: string + } +} + +export type CommandExecuted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "command.executed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type ProjectUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "project.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } +} + +export type SessionIdle = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.idle" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type QuestionAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool + } +} + +export type SessionCompacted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.compacted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type VcsBranchUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "vcs.branch.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + branch?: string + } +} + +export type WorkspaceReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + } +} + +export type WorkspaceFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + message: string + } +} + +export type WorkspaceStatus = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type WorktreeReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + branch?: string + } +} + +export type WorktreeFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + message: string + } +} + +export type ServerConnected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "server.connected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type GlobalDisposed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "global.disposed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type QuestionV2Request = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool +} + +export type QuestionV2Reply = { + /** + * User answers in order of questions (each answer is an array of selected labels) + */ + answers: Array +} + +export type ReferenceLocalSource = { + type: "local" + path: string + description?: string + hidden?: boolean +} + +export type ReferenceGitSource = { + type: "git" + repository: string + branch?: string + description?: string + hidden?: boolean +} + +export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource + +export type ReferenceInfo = { + name: string + path: string + description?: string + hidden?: boolean + source: ReferenceSource +} + +export type ProjectCopyCopy = { + directory: string +} + +export type EventModelsDevRefreshed = { + id: string + type: "models-dev.refreshed" + properties: { + [key: string]: unknown + } +} + +export type EventIntegrationUpdated = { + id: string + type: "integration.updated" + properties: { + [key: string]: unknown + } +} + +export type EventIntegrationConnectionUpdated = { + id: string + type: "integration.connection.updated" + properties: { + integrationID: string + } +} + +export type EventCatalogUpdated = { + id: string + type: "catalog.updated" + properties: { + [key: string]: unknown + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + +export type EventMessageUpdated = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionNextAgentSwitched = { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + messageID: string + agent: string + } +} + +export type EventSessionNextModelSwitched = { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } +} + +export type EventSessionNextMoved = { + id: string + type: "session.next.moved" + properties: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } +} + +export type EventSessionNextPrompted = { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type EventSessionNextPromptAdmitted = { + id: string + type: "session.next.prompt.admitted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type EventSessionNextContextUpdated = { + id: string + type: "session.next.context.updated" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type EventSessionNextSynthetic = { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type EventSessionNextShellStarted = { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } +} + +export type EventSessionNextShellEnded = { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type EventSessionNextStepStarted = { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type EventSessionNextStepEnded = { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } +} + +export type EventSessionNextStepFailed = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } +} + +export type EventSessionNextTextStarted = { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } +} + +export type EventSessionNextTextDelta = { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + delta: string + } +} + +export type EventSessionNextTextEnded = { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } +} + +export type EventSessionNextReasoningStarted = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } +} + +export type EventSessionNextReasoningDelta = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } +} + +export type EventSessionNextToolInputStarted = { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta = { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded = { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled = { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type EventSessionNextToolProgress = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type EventSessionNextToolSuccess = { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type EventSessionNextToolFailed = { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type EventSessionNextRetried = { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompactionStarted = { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } +} + +export type EventSessionNextCompactionDelta = { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type EventSessionNextCompactionEnded = { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + } +} + +export type EventSessionNextRevertStaged = { + id: string + type: "session.next.revert.staged" + properties: { + timestamp: number + sessionID: string + revert: RevertState + } +} + +export type EventSessionNextRevertCleared = { + id: string + type: "session.next.revert.cleared" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextRevertCommitted = { + id: string + type: "session.next.revert.committed" + properties: { + timestamp: number + sessionID: string + messageID: string + } +} + +export type EventMessagePartDelta = { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type EventSessionDiff = { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + } +} + +export type EventInstallationUpdated = { + id: string + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable = { + id: string + type: "installation.update-available" + properties: { + version: string + } +} + +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +export type EventReferenceUpdated = { + id: string + type: "reference.updated" + properties: { + [key: string]: unknown + } +} + +export type EventPermissionV2Asked = { + id: string + type: "permission.v2.asked" + properties: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } +} + +export type EventPermissionV2Replied = { + id: string + type: "permission.v2.replied" + properties: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + +export type EventPluginAdded = { + id: string + type: "plugin.added" + properties: { + id: string + } +} + +export type EventProjectDirectoriesUpdated = { + id: string + type: "project.directories.updated" + properties: { + projectID: string + } +} + +export type EventFileWatcherUpdated = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventPtyCreated = { + id: string + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + id: string + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + id: string + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventQuestionV2Asked = { + id: string + type: "question.v2.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } +} + +export type EventQuestionV2Replied = { + id: string + type: "question.v2.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionV2Rejected = { + id: string + type: "question.v2.rejected" + properties: { + sessionID: string + requestID: string + } +} + +export type EventTodoUpdated = { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventLspUpdated = { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +export type EventPermissionAsked = { + id: string + type: "permission.asked" + properties: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } +} + +export type EventPermissionReplied = { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventMcpToolsChanged = { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventProjectUpdated = { + id: string + type: "project.updated" + properties: { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } +} + +export type EventSessionStatus = { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + id: string + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventQuestionAsked = { + id: string + type: "question.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool + } +} + +export type EventQuestionReplied = { + id: string + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected = { + id: string + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + +export type EventSessionCompacted = { + id: string + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventVcsBranchUpdated = { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventWorkspaceReady = { + id: string + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + id: string + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceStatus = { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventWorktreeReady = { + id: string + type: "worktree.ready" + properties: { + name: string + branch?: string + } +} + +export type EventWorktreeFailed = { + id: string + type: "worktree.failed" + properties: { + message: string + } +} + +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type CredentialOAuth = { + type: "oauth" + methodID: string + refresh: string + access: string + expires: number + metadata?: { + [key: string]: unknown + } +} + +export type CredentialKey = { + type: "key" + key: string + metadata?: { + [key: string]: unknown + } +} + +export type SkillV2DirectorySource = { + type: "directory" + path: string +} + +export type SkillV2UrlSource = { + type: "url" + url: string +} + +export type SkillV2EmbeddedSource = { + type: "embedded" + skill: SkillV2Info +} + +export type BadRequestError = { + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } +} + +export type AuthRemoveData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthRemoveErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] + +export type AuthRemoveResponses = { + /** + * Successfully removed authentication credentials + */ + 200: boolean +} + +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] + +export type AuthSetData = { + body?: Auth + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthSetErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] + +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean +} + +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] + +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn" + /** + * Log message + */ + message: string + extra?: { + [key: string]: unknown + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/log" +} + +export type AppLogErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type AppLogError = AppLogErrors[keyof AppLogErrors] + +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean +} + +export type AppLogResponse = AppLogResponses[keyof AppLogResponses] + +export type ExperimentalControlPlaneMoveSessionData = { + body?: { + sessionID: string + destination: MoveSessionDestination + moveChanges?: boolean + } + path?: never + query?: never + url: "/experimental/control-plane/move-session" +} + +export type ExperimentalControlPlaneMoveSessionErrors = { + /** + * MoveSessionError | InvalidRequestError + */ + 400: MoveSessionError | InvalidRequestError +} + +export type ExperimentalControlPlaneMoveSessionError = + ExperimentalControlPlaneMoveSessionErrors[keyof ExperimentalControlPlaneMoveSessionErrors] + +export type ExperimentalControlPlaneMoveSessionResponses = { + /** + * Session moved + */ + 204: void +} + +export type ExperimentalControlPlaneMoveSessionResponse = + ExperimentalControlPlaneMoveSessionResponses[keyof ExperimentalControlPlaneMoveSessionResponses] + +export type GlobalHealthData = { + body?: never + path?: never + query?: never + url: "/global/health" +} + +export type GlobalHealthErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalHealthError = GlobalHealthErrors[keyof GlobalHealthErrors] + +export type GlobalHealthResponses = { + /** + * Health information + */ + 200: { + healthy: true + version: string + } +} + +export type GlobalHealthResponse = GlobalHealthResponses[keyof GlobalHealthResponses] + +export type GlobalEventData = { + body?: never + path?: never + query?: never + url: "/global/event" +} + +export type GlobalEventErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalEventError = GlobalEventErrors[keyof GlobalEventErrors] + +export type GlobalEventResponses = { + /** + * Event stream + */ + 200: GlobalEvent +} + +export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses] + +export type GlobalConfigGetData = { + body?: never + path?: never + query?: never + url: "/global/config" +} + +export type GlobalConfigGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalConfigGetError = GlobalConfigGetErrors[keyof GlobalConfigGetErrors] + +export type GlobalConfigGetResponses = { + /** + * Get global config info + */ + 200: Config +} + +export type GlobalConfigGetResponse = GlobalConfigGetResponses[keyof GlobalConfigGetResponses] + +export type GlobalConfigUpdateData = { + body?: Config + path?: never + query?: never + url: "/global/config" +} + +export type GlobalConfigUpdateErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type GlobalConfigUpdateError = GlobalConfigUpdateErrors[keyof GlobalConfigUpdateErrors] + +export type GlobalConfigUpdateResponses = { + /** + * Successfully updated global config + */ + 200: Config +} + +export type GlobalConfigUpdateResponse = GlobalConfigUpdateResponses[keyof GlobalConfigUpdateResponses] + +export type GlobalDisposeData = { + body?: never + path?: never + query?: never + url: "/global/dispose" +} + +export type GlobalDisposeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalDisposeError = GlobalDisposeErrors[keyof GlobalDisposeErrors] + +export type GlobalDisposeResponses = { + /** + * Global disposed + */ + 200: boolean +} + +export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses] + +export type GlobalUpgradeData = { + body?: { + target: string + } + path?: never + query?: never + url: "/global/upgrade" +} + +export type GlobalUpgradeErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type GlobalUpgradeError = GlobalUpgradeErrors[keyof GlobalUpgradeErrors] + +export type GlobalUpgradeResponses = { + /** + * Upgrade result + */ + 200: + | { + success: true + version: string + } + | { + success: false + error: string + } +} + +export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses] + +export type EventSubscribeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/event" +} + +export type EventSubscribeResponses = { + /** + * Event stream + */ + 200: Event +} + +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] + +export type ConfigGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/config" +} + +export type ConfigGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ConfigGetError = ConfigGetErrors[keyof ConfigGetErrors] + +export type ConfigGetResponses = { + /** + * Get config info + */ + 200: Config +} + +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] + +export type ConfigUpdateData = { + body?: Config + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/config" +} + +export type ConfigUpdateErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] + +export type ConfigUpdateResponses = { + /** + * Successfully updated config + */ + 200: Config +} + +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] + +export type ConfigProvidersData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/config/providers" +} + +export type ConfigProvidersErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ConfigProvidersError = ConfigProvidersErrors[keyof ConfigProvidersErrors] + +export type ConfigProvidersResponses = { + /** + * List of providers + */ + 200: { + providers: Array + default: { + [key: string]: string + } + } +} + +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] + +export type ExperimentalCapabilitiesGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/capabilities" +} + +export type ExperimentalCapabilitiesGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalCapabilitiesGetError = + ExperimentalCapabilitiesGetErrors[keyof ExperimentalCapabilitiesGetErrors] + +export type ExperimentalCapabilitiesGetResponses = { + /** + * Experimental capabilities + */ + 200: ExperimentalCapabilities +} + +export type ExperimentalCapabilitiesGetResponse = + ExperimentalCapabilitiesGetResponses[keyof ExperimentalCapabilitiesGetResponses] + +export type ExperimentalConsoleGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console" +} + +export type ExperimentalConsoleGetErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError +} + +export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors] + +export type ExperimentalConsoleGetResponses = { + /** + * Active Console provider metadata + */ + 200: ConsoleState +} + +export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] + +export type ExperimentalConsoleListOrgsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/orgs" +} + +export type ExperimentalConsoleListOrgsErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError +} + +export type ExperimentalConsoleListOrgsError = + ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors] + +export type ExperimentalConsoleListOrgsResponses = { + /** + * Switchable Console orgs + */ + 200: { + orgs: Array<{ + accountID: string + accountEmail: string + accountUrl: string + orgID: string + orgName: string + active: boolean + }> + } +} + +export type ExperimentalConsoleListOrgsResponse = + ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] + +export type ExperimentalConsoleSwitchOrgData = { + body?: { + accountID: string + orgID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/switch" +} + +export type ExperimentalConsoleSwitchOrgResponses = { + /** + * Switch success + */ + 200: boolean +} + +export type ExperimentalConsoleSwitchOrgResponse = + ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] + +export type ToolListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + provider: string + model: string + } + url: "/experimental/tool" +} + +export type ToolListErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type ToolListError = ToolListErrors[keyof ToolListErrors] + +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList +} + +export type ToolListResponse = ToolListResponses[keyof ToolListResponses] + +export type ToolIdsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/tool/ids" +} + +export type ToolIdsErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] + +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds +} + +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] + +export type WorktreeRemoveData = { + body?: WorktreeRemoveInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeRemoveErrors = { + /** + * WorktreeError | InvalidRequestError + */ + 400: WorktreeError | InvalidRequestError +} + +export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] + +export type WorktreeRemoveResponses = { + /** + * Worktree removed + */ + 200: boolean +} + +export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] + +export type WorktreeListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeListErrors = { + /** + * WorktreeError | InvalidRequestError + */ + 400: WorktreeError | InvalidRequestError +} + +export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors] + +export type WorktreeListResponses = { + /** + * List of worktree directories + */ + 200: Array +} + +export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] + +export type WorktreeCreateData = { + body?: WorktreeCreateInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeCreateErrors = { + /** + * WorktreeError | InvalidRequestError + */ + 400: WorktreeError | InvalidRequestError +} + +export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] + +export type WorktreeCreateResponses = { + /** + * Worktree created + */ + 200: Worktree +} + +export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] + +export type WorktreeResetData = { + body?: WorktreeResetInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree/reset" +} + +export type WorktreeResetErrors = { + /** + * WorktreeError | InvalidRequestError + */ + 400: WorktreeError | InvalidRequestError +} + +export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] + +export type WorktreeResetResponses = { + /** + * Worktree reset + */ + 200: boolean +} + +export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] + +export type ExperimentalSessionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + } + url: "/experimental/session" +} + +export type ExperimentalSessionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalSessionListError = ExperimentalSessionListErrors[keyof ExperimentalSessionListErrors] + +export type ExperimentalSessionListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] + +export type ExperimentalSessionBackgroundData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/session/{sessionID}/background" +} + +export type ExperimentalSessionBackgroundErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type ExperimentalSessionBackgroundError = + ExperimentalSessionBackgroundErrors[keyof ExperimentalSessionBackgroundErrors] + +export type ExperimentalSessionBackgroundResponses = { + /** + * Backgrounded subagents + */ + 200: boolean +} + +export type ExperimentalSessionBackgroundResponse = + ExperimentalSessionBackgroundResponses[keyof ExperimentalSessionBackgroundResponses] + +export type ExperimentalResourceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/resource" +} + +export type ExperimentalResourceListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalResourceListError = ExperimentalResourceListErrors[keyof ExperimentalResourceListErrors] + +export type ExperimentalResourceListResponses = { + /** + * MCP resources + */ + 200: { + [key: string]: McpResource + } +} + +export type ExperimentalResourceListResponse = + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] + +export type FindTextData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + pattern: string + } + url: "/find" +} + +export type FindTextErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindTextError = FindTextErrors[keyof FindTextErrors] + +export type FindTextResponses = { + /** + * Matches + */ + 200: Array<{ + path: { + text: string + } + lines: { + text: string + } + line_number: number + absolute_offset: number + submatches: Array<{ + match: { + text: string + } + start: number + end: number + }> + }> +} + +export type FindTextResponse = FindTextResponses[keyof FindTextResponses] + +export type FindFilesData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + } + url: "/find/file" +} + +export type FindFilesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindFilesError = FindFilesErrors[keyof FindFilesErrors] + +export type FindFilesResponses = { + /** + * File paths + */ + 200: Array +} + +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] + +export type FindSymbolsData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + } + url: "/find/symbol" +} + +export type FindSymbolsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindSymbolsError = FindSymbolsErrors[keyof FindSymbolsErrors] + +export type FindSymbolsResponses = { + /** + * Symbols + */ + 200: Array +} + +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] + +export type FileListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file" +} + +export type FileListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileListError = FileListErrors[keyof FileListErrors] + +export type FileListResponses = { + /** + * Files and directories + */ + 200: Array +} + +export type FileListResponse = FileListResponses[keyof FileListResponses] + +export type FileReadData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file/content" +} + +export type FileReadErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileReadError = FileReadErrors[keyof FileReadErrors] + +export type FileReadResponses = { + /** + * File content + */ + 200: FileContent +} + +export type FileReadResponse = FileReadResponses[keyof FileReadResponses] + +export type FileStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/file/status" +} + +export type FileStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileStatusError = FileStatusErrors[keyof FileStatusErrors] + +export type FileStatusResponses = { + /** + * File status + */ + 200: Array +} + +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] + +export type InstanceDisposeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/instance/dispose" +} + +export type InstanceDisposeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type InstanceDisposeError = InstanceDisposeErrors[keyof InstanceDisposeErrors] + +export type InstanceDisposeResponses = { + /** + * Instance disposed + */ + 200: boolean +} + +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] + +export type PathGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/path" +} + +export type PathGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PathGetError = PathGetErrors[keyof PathGetErrors] + +export type PathGetResponses = { + /** + * Path + */ + 200: Path +} + +export type PathGetResponse = PathGetResponses[keyof PathGetResponses] + +export type VcsGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs" +} + +export type VcsGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsGetError = VcsGetErrors[keyof VcsGetErrors] + +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo +} + +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] + +export type VcsStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/status" +} + +export type VcsStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsStatusError = VcsStatusErrors[keyof VcsStatusErrors] + +export type VcsStatusResponses = { + /** + * VCS status + */ + 200: Array +} + +export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses] + +export type VcsDiffData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + mode: "git" | "branch" + context?: number + } + url: "/vcs/diff" +} + +export type VcsDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsDiffError = VcsDiffErrors[keyof VcsDiffErrors] + +export type VcsDiffResponses = { + /** + * VCS diff + */ + 200: Array +} + +export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] + +export type VcsDiffRawData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/diff/raw" +} + +export type VcsDiffRawErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsDiffRawError = VcsDiffRawErrors[keyof VcsDiffRawErrors] + +export type VcsDiffRawResponses = { + /** + * Raw VCS diff + */ + 200: string +} + +export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses] + +export type VcsApplyData = { + body?: { + patch: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/apply" +} + +export type VcsApplyErrors = { + /** + * VcsApplyError | InvalidRequestError + */ + 400: VcsApplyError | InvalidRequestError +} + +export type VcsApplyError2 = VcsApplyErrors[keyof VcsApplyErrors] + +export type VcsApplyResponses = { + /** + * VCS patch applied + */ + 200: { + applied: boolean + } +} + +export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses] + +export type CommandListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/command" +} + +export type CommandListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type CommandListError = CommandListErrors[keyof CommandListErrors] + +export type CommandListResponses = { + /** + * List of commands + */ + 200: Array +} + +export type CommandListResponse = CommandListResponses[keyof CommandListResponses] + +export type AppAgentsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/agent" +} + +export type AppAgentsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppAgentsError = AppAgentsErrors[keyof AppAgentsErrors] + +export type AppAgentsResponses = { + /** + * List of agents + */ + 200: Array +} + +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] + +export type AppSkillsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/skill" +} + +export type AppSkillsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppSkillsError = AppSkillsErrors[keyof AppSkillsErrors] + +export type AppSkillsResponses = { + /** + * List of skills + */ + 200: Array<{ + name: string + description?: string + location: string + content: string + }> +} + +export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] + +export type LspStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp" +} + +export type LspStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspStatusError = LspStatusErrors[keyof LspStatusErrors] + +export type LspStatusResponses = { + /** + * LSP server status + */ + 200: Array +} + +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] + +export type FormatterStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/formatter" +} + +export type FormatterStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FormatterStatusError = FormatterStatusErrors[keyof FormatterStatusErrors] + +export type FormatterStatusResponses = { + /** + * Formatter status + */ + 200: Array +} + +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] + +export type McpStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpStatusError = McpStatusErrors[keyof McpStatusErrors] + +export type McpStatusResponses = { + /** + * MCP server status + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] + +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpAddErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type McpAddError = McpAddErrors[keyof McpAddErrors] + +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] + +export type McpAuthRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundError +} + +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] + +export type McpAuthRemoveResponses = { + /** + * OAuth credentials removed + */ + 200: { + success: true + } +} + +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] + +export type McpAuthStartData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthStartErrors = { + /** + * McpUnsupportedOAuthError | InvalidRequestError + */ + 400: McpUnsupportedOAuthError | InvalidRequestError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundError +} + +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] + +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + authorizationUrl: string + oauthState: string + } +} + +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] + +export type McpAuthCallbackData = { + body?: { + code: string + } + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/callback" +} + +export type McpAuthCallbackErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundError +} + +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] + +export type McpAuthCallbackResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] + +export type McpAuthAuthenticateData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/authenticate" +} + +export type McpAuthAuthenticateErrors = { + /** + * McpUnsupportedOAuthError | InvalidRequestError + */ + 400: McpUnsupportedOAuthError | InvalidRequestError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundError +} + +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] + +export type McpAuthAuthenticateResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] + +export type McpConnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/connect" +} + +export type McpConnectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundError +} + +export type McpConnectError = McpConnectErrors[keyof McpConnectErrors] + +export type McpConnectResponses = { + /** + * MCP server connected successfully + */ + 200: boolean +} + +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] + +export type McpDisconnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/disconnect" +} + +export type McpDisconnectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundError +} + +export type McpDisconnectError = McpDisconnectErrors[keyof McpDisconnectErrors] + +export type McpDisconnectResponses = { + /** + * MCP server disconnected successfully + */ + 200: boolean +} + +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] + +export type ProjectListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project" +} + +export type ProjectListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectListError = ProjectListErrors[keyof ProjectListErrors] + +export type ProjectListResponses = { + /** + * List of projects + */ + 200: Array +} + +export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] + +export type ProjectCurrentData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project/current" +} + +export type ProjectCurrentErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectCurrentError = ProjectCurrentErrors[keyof ProjectCurrentErrors] + +export type ProjectCurrentResponses = { + /** + * Current project information + */ + 200: Project +} + +export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] + +export type ProjectInitGitData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project/git/init" +} + +export type ProjectInitGitErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectInitGitError = ProjectInitGitErrors[keyof ProjectInitGitErrors] + +export type ProjectInitGitResponses = { + /** + * Project information after git initialization + */ + 200: Project +} + +export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] + +export type ProjectUpdateData = { + body?: { + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + } + path: { + projectID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/project/{projectID}" +} + +export type ProjectUpdateErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * ProjectNotFoundError + */ + 404: ProjectNotFoundError +} + +export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] + +export type ProjectUpdateResponses = { + /** + * Updated project information + */ + 200: Project +} + +export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] + +export type ProjectDirectoriesData = { + body?: never + path: { + projectID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/project/{projectID}/directories" +} + +export type ProjectDirectoriesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectDirectoriesError = ProjectDirectoriesErrors[keyof ProjectDirectoriesErrors] + +export type ProjectDirectoriesResponses = { + /** + * Project directories + */ + 200: ProjectDirectories +} + +export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof ProjectDirectoriesResponses] + +export type ExperimentalProjectCopyGenerateNameData = { + body?: { + context?: string + } + path: { + projectID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/project/{projectID}/copy/generate-name" +} + +export type ExperimentalProjectCopyGenerateNameErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalProjectCopyGenerateNameError = + ExperimentalProjectCopyGenerateNameErrors[keyof ExperimentalProjectCopyGenerateNameErrors] + +export type ExperimentalProjectCopyGenerateNameResponses = { + /** + * Success + */ + 200: { + name: string + } +} + +export type ExperimentalProjectCopyGenerateNameResponse = + ExperimentalProjectCopyGenerateNameResponses[keyof ExperimentalProjectCopyGenerateNameResponses] + +export type PtyShellsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/pty/shells" +} + +export type PtyShellsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyShellsError = PtyShellsErrors[keyof PtyShellsErrors] + +export type PtyShellsResponses = { + /** + * List of shells + */ + 200: Array<{ + path: string + name: string + acceptable: boolean + }> +} + +export type PtyShellsResponse = PtyShellsResponses[keyof PtyShellsResponses] + +export type PtyListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/pty" +} + +export type PtyListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyListError = PtyListErrors[keyof PtyListErrors] + +export type PtyListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type PtyListResponse = PtyListResponses[keyof PtyListResponses] + +export type PtyCreateData = { + body?: { + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/pty" +} + +export type PtyCreateErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors] + +export type PtyCreateResponses = { + /** + * Created session + */ + 200: Pty +} + +export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses] + +export type PtyRemoveData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}" +} + +export type PtyRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] + +export type PtyRemoveResponses = { + /** + * Session removed + */ + 200: boolean +} + +export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses] + +export type PtyGetData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}" +} + +export type PtyGetErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] + +export type PtyGetResponses = { + /** + * Session info + */ + 200: Pty +} + +export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] + +export type PtyUpdateData = { + body?: { + title?: string + size?: { + rows: number + cols: number + } + } + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}" +} + +export type PtyUpdateErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] + +export type PtyUpdateResponses = { + /** + * Updated session + */ + 200: Pty +} + +export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] + +export type PtyConnectTokenData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}/connect-token" +} + +export type PtyConnectTokenErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * PtyForbiddenError + */ + 403: PtyForbiddenError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors] + +export type PtyConnectTokenResponses = { + /** + * WebSocket connect token + */ + 200: PtyTicketConnectToken +} + +export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] + +export type QuestionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/question" +} + +export type QuestionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type QuestionListError = QuestionListErrors[keyof QuestionListErrors] + +export type QuestionListResponses = { + /** + * List of pending questions + */ + 200: Array +} + +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] + +export type QuestionReplyData = { + body?: { + /** + * User answers in order of questions (each answer is an array of selected labels) + */ + answers: Array + } + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/question/{requestID}/reply" +} + +export type QuestionReplyErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * QuestionNotFoundError + */ + 404: QuestionNotFoundError +} + +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] + +export type QuestionReplyResponses = { + /** + * Question answered successfully + */ + 200: boolean +} + +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] + +export type QuestionRejectData = { + body?: never + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/question/{requestID}/reject" +} + +export type QuestionRejectErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * QuestionNotFoundError + */ + 404: QuestionNotFoundError +} + +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] + +export type QuestionRejectResponses = { + /** + * Question rejected successfully + */ + 200: boolean +} + +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] + +export type PermissionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/permission" +} + +export type PermissionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PermissionListError = PermissionListErrors[keyof PermissionListErrors] + +export type PermissionListResponses = { + /** + * List of pending permissions + */ + 200: Array +} + +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] + +export type PermissionReplyData = { + body?: { + reply: "once" | "always" | "reject" + message?: string + } + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/permission/{requestID}/reply" +} + +export type PermissionReplyErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * PermissionNotFoundError + */ + 404: PermissionNotFoundError +} + +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] + +export type PermissionReplyResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] + +export type ProviderListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/provider" +} + +export type ProviderListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderListError = ProviderListErrors[keyof ProviderListErrors] + +export type ProviderListResponses = { + /** + * List of providers + */ + 200: { + all: Array + default: { + [key: string]: string + } + connected: Array + } +} + +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] + +export type ProviderAuthData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/provider/auth" +} + +export type ProviderAuthErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderAuthError2 = ProviderAuthErrors[keyof ProviderAuthErrors] + +export type ProviderAuthResponses = { + /** + * Provider auth methods + */ + 200: { + [key: string]: Array + } +} + +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] + +export type ProviderOauthAuthorizeData = { + body?: { + /** + * Auth method index + */ + method: number + inputs?: { + [key: string]: string + } + } + path: { + providerID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/provider/{providerID}/oauth/authorize" +} + +export type ProviderOauthAuthorizeErrors = { + /** + * ProviderAuthError | InvalidRequestError + */ + 400: ProviderAuthError1 | InvalidRequestError +} + +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] + +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: ProviderAuthAuthorization +} + +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] + +export type ProviderOauthCallbackData = { + body?: { + /** + * Auth method index + */ + method: number + code?: string + } + path: { + providerID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/provider/{providerID}/oauth/callback" +} + +export type ProviderOauthCallbackErrors = { + /** + * ProviderAuthError | InvalidRequestError + */ + 400: ProviderAuthError1 | InvalidRequestError +} + +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] + +export type ProviderOauthCallbackResponses = { + /** + * OAuth callback processed successfully + */ + 200: boolean +} + +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] + +export type SessionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + scope?: "project" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + limit?: number + } + url: "/session" +} + +export type SessionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionListError = SessionListErrors[keyof SessionListErrors] + +export type SessionListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type SessionListResponse = SessionListResponses[keyof SessionListResponses] + +export type SessionCreateData = { + body?: { + parentID?: string + title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + metadata?: { + [key: string]: unknown + } + permission?: PermissionRuleset + workspaceID?: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/session" +} + +export type SessionCreateErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors] + +export type SessionCreateResponses = { + /** + * Successfully created session + */ + 200: Session +} + +export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] + +export type SessionStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/session/status" +} + +export type SessionStatusErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors] + +export type SessionStatusResponses = { + /** + * Get session status + */ + 200: { + [key: string]: SessionStatus + } +} + +export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses] + +export type SessionDeleteData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}" +} + +export type SessionDeleteErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors] + +export type SessionDeleteResponses = { + /** + * Successfully deleted session + */ + 200: boolean +} + +export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses] + +export type SessionGetData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}" +} + +export type SessionGetErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionGetError = SessionGetErrors[keyof SessionGetErrors] + +export type SessionGetResponses = { + /** + * Get session + */ + 200: Session +} + +export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] + +export type SessionUpdateData = { + body?: { + title?: string + metadata?: { + [key: string]: unknown + } + permission?: PermissionRuleset + time?: { + archived?: number + } + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}" +} + +export type SessionUpdateErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors] + +export type SessionUpdateResponses = { + /** + * Successfully updated session + */ + 200: Session +} + +export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses] + +export type SessionChildrenData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/children" +} + +export type SessionChildrenErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors] + +export type SessionChildrenResponses = { + /** + * List of children + */ + 200: Array +} + +export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] + +export type SessionTodoData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/todo" +} + +export type SessionTodoErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors] + +export type SessionTodoResponses = { + /** + * Todo list + */ + 200: Array +} + +export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] + +export type SessionDiffData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + messageID?: string + } + url: "/session/{sessionID}/diff" +} + +export type SessionDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors] + +export type SessionDiffResponses = { + /** + * Successfully retrieved diff + */ + 200: Array +} + +export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] + +export type SessionMessagesData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + limit?: number + before?: string + } + url: "/session/{sessionID}/message" +} + +export type SessionMessagesErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors] + +export type SessionMessagesResponses = { + /** + * List of messages + */ + 200: Array<{ + info: Message + parts: Array + }> +} + +export type SessionMessagesResponse2 = SessionMessagesResponses[keyof SessionMessagesResponses] + +export type SessionPromptData = { + body?: { + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + tools?: { + [key: string]: boolean + } + format?: OutputFormat + system?: string + variant?: string + parts: Array + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/message" +} + +export type SessionPromptErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors] + +export type SessionPromptResponses = { + /** + * Created message + */ + 200: { + info: AssistantMessage + parts: Array + } +} + +export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses] + +export type SessionDeleteMessageData = { + body?: never + path: { + sessionID: string + messageID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/message/{messageID}" +} + +export type SessionDeleteMessageErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * SessionBusyError + */ + 409: SessionBusyError +} + +export type SessionDeleteMessageError = SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors] + +export type SessionDeleteMessageResponses = { + /** + * Successfully deleted message + */ + 200: boolean +} + +export type SessionDeleteMessageResponse = SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses] + +export type SessionMessageData = { + body?: never + path: { + sessionID: string + messageID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/message/{messageID}" +} + +export type SessionMessageErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors] + +export type SessionMessageResponses = { + /** + * Message + */ + 200: { + info: Message + parts: Array + } +} + +export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] + +export type SessionForkData = { + body?: { + messageID?: string + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/fork" +} + +export type SessionForkErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionForkError = SessionForkErrors[keyof SessionForkErrors] + +export type SessionForkResponses = { + /** + * 200 + */ + 200: Session +} + +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] + +export type SessionAbortData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/abort" +} + +export type SessionAbortErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] + +export type SessionAbortResponses = { + /** + * Aborted session + */ + 200: boolean +} + +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] + +export type SessionInitData = { + body?: { + modelID: string + providerID: string + messageID: string + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/init" +} + +export type SessionInitErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] + +export type SessionInitResponses = { + /** + * 200 + */ + 200: boolean +} + +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] + +export type SessionUnshareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionUnshareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError +} + +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] + +export type SessionUnshareResponses = { + /** + * Successfully unshared session + */ + 200: Session +} + +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] + +export type SessionShareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionShareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError +} + +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] + +export type SessionShareResponses = { + /** + * Successfully shared session + */ + 200: Session +} + +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] + +export type SessionSummarizeData = { + body?: { + providerID: string + modelID: string + auto?: boolean + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/summarize" +} + +export type SessionSummarizeErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] + +export type SessionSummarizeResponses = { + /** + * Summarized session + */ + 200: boolean +} + +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] + +export type SessionPromptAsyncData = { + body?: { + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + tools?: { + [key: string]: boolean + } + format?: OutputFormat + system?: string + variant?: string + parts: Array + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/prompt_async" +} + +export type SessionPromptAsyncErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors] + +export type SessionPromptAsyncResponses = { + /** + * Prompt accepted + */ + 204: void +} + +export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses] + +export type SessionCommandData = { + body?: { + messageID?: string + agent?: string + model?: string + arguments: string + command: string + variant?: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource + }> + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/command" +} + +export type SessionCommandErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors] + +export type SessionCommandResponses = { + /** + * Created message + */ + 200: { + info: AssistantMessage + parts: Array + } +} + +export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] + +export type SessionShellData = { + body?: { + messageID?: string + agent: string + model?: { + providerID: string + modelID: string + } + command: string + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/shell" +} + +export type SessionShellErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * SessionBusyError + */ + 409: SessionBusyError +} + +export type SessionShellError = SessionShellErrors[keyof SessionShellErrors] + +export type SessionShellResponses = { + /** + * Created message + */ + 200: { + info: Message + parts: Array + } +} + +export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] + +export type SessionRevertData = { + body?: { + messageID: string + partID?: string + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/revert" +} + +export type SessionRevertErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * SessionBusyError + */ + 409: SessionBusyError +} + +export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors] + +export type SessionRevertResponses = { + /** + * Updated session + */ + 200: Session +} + +export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] + +export type SessionUnrevertData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/unrevert" +} + +export type SessionUnrevertErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * SessionBusyError + */ + 409: SessionBusyError +} + +export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors] + +export type SessionUnrevertResponses = { + /** + * Updated session + */ + 200: Session +} + +export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] + +export type PermissionRespondData = { + body?: { + response: "once" | "always" | "reject" + } + path: { + sessionID: string + permissionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/permissions/{permissionID}" +} + +export type PermissionRespondErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError | PermissionNotFoundError + */ + 404: NotFoundError | PermissionNotFoundError +} + +export type PermissionRespondError = PermissionRespondErrors[keyof PermissionRespondErrors] + +export type PermissionRespondResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] + +export type PartDeleteData = { + body?: never + path: { + sessionID: string + messageID: string + partID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/message/{messageID}/part/{partID}" +} + +export type PartDeleteErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] + +export type PartDeleteResponses = { + /** + * Successfully deleted part + */ + 200: boolean +} + +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] + +export type PartUpdateData = { + body?: Part + path: { + sessionID: string + messageID: string + partID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/message/{messageID}/part/{partID}" +} + +export type PartUpdateErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] + +export type PartUpdateResponses = { + /** + * Successfully updated part + */ + 200: Part +} + +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] + +export type SyncStartData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/start" +} + +export type SyncStartErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncStartError = SyncStartErrors[keyof SyncStartErrors] + +export type SyncStartResponses = { + /** + * Workspace sync started + */ + 200: boolean +} + +export type SyncStartResponse = SyncStartResponses[keyof SyncStartResponses] + +export type SyncReplayData = { + body?: { + directory: string + events: Array<{ + id: string + aggregateID: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/replay" +} + +export type SyncReplayErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type SyncReplayError = SyncReplayErrors[keyof SyncReplayErrors] + +export type SyncReplayResponses = { + /** + * Replayed sync events + */ + 200: { + sessionID: string + } +} + +export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] + +export type SyncStealData = { + body?: { + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/steal" +} + +export type SyncStealErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type SyncStealError = SyncStealErrors[keyof SyncStealErrors] + +export type SyncStealResponses = { + /** + * Session stolen into workspace + */ + 200: { + sessionID: string + } +} + +export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses] + +export type SyncHistoryListData = { + body?: { + [key: string]: number + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/history" +} + +export type SyncHistoryListErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type SyncHistoryListError = SyncHistoryListErrors[keyof SyncHistoryListErrors] + +export type SyncHistoryListResponses = { + /** + * Sync events + */ + 200: Array<{ + id: string + aggregate_id: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> +} + +export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] + +export type TuiAppendPromptData = { + body?: { + text: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/append-prompt" +} + +export type TuiAppendPromptErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type TuiAppendPromptError = TuiAppendPromptErrors[keyof TuiAppendPromptErrors] + +export type TuiAppendPromptResponses = { + /** + * Prompt processed successfully + */ + 200: boolean +} + +export type TuiAppendPromptResponse = TuiAppendPromptResponses[keyof TuiAppendPromptResponses] + +export type TuiOpenHelpData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/open-help" +} + +export type TuiOpenHelpErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenHelpError = TuiOpenHelpErrors[keyof TuiOpenHelpErrors] + +export type TuiOpenHelpResponses = { + /** + * Help dialog opened successfully + */ + 200: boolean +} + +export type TuiOpenHelpResponse = TuiOpenHelpResponses[keyof TuiOpenHelpResponses] + +export type TuiOpenSessionsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/open-sessions" +} + +export type TuiOpenSessionsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenSessionsError = TuiOpenSessionsErrors[keyof TuiOpenSessionsErrors] + +export type TuiOpenSessionsResponses = { + /** + * Session dialog opened successfully + */ + 200: boolean +} + +export type TuiOpenSessionsResponse = TuiOpenSessionsResponses[keyof TuiOpenSessionsResponses] + +export type TuiOpenThemesData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/open-themes" +} + +export type TuiOpenThemesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenThemesError = TuiOpenThemesErrors[keyof TuiOpenThemesErrors] + +export type TuiOpenThemesResponses = { + /** + * Theme dialog opened successfully + */ + 200: boolean +} + +export type TuiOpenThemesResponse = TuiOpenThemesResponses[keyof TuiOpenThemesResponses] + +export type TuiOpenModelsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/open-models" +} + +export type TuiOpenModelsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenModelsError = TuiOpenModelsErrors[keyof TuiOpenModelsErrors] + +export type TuiOpenModelsResponses = { + /** + * Model dialog opened successfully + */ + 200: boolean +} + +export type TuiOpenModelsResponse = TuiOpenModelsResponses[keyof TuiOpenModelsResponses] + +export type TuiSubmitPromptData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/submit-prompt" +} + +export type TuiSubmitPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiSubmitPromptError = TuiSubmitPromptErrors[keyof TuiSubmitPromptErrors] + +export type TuiSubmitPromptResponses = { + /** + * Prompt submitted successfully + */ + 200: boolean +} + +export type TuiSubmitPromptResponse = TuiSubmitPromptResponses[keyof TuiSubmitPromptResponses] + +export type TuiClearPromptData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/clear-prompt" +} + +export type TuiClearPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiClearPromptError = TuiClearPromptErrors[keyof TuiClearPromptErrors] + +export type TuiClearPromptResponses = { + /** + * Prompt cleared successfully + */ + 200: boolean +} + +export type TuiClearPromptResponse = TuiClearPromptResponses[keyof TuiClearPromptResponses] + +export type TuiExecuteCommandData = { + body?: { + command: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/execute-command" +} + +export type TuiExecuteCommandErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type TuiExecuteCommandError = TuiExecuteCommandErrors[keyof TuiExecuteCommandErrors] + +export type TuiExecuteCommandResponses = { + /** + * Command executed successfully + */ + 200: boolean +} + +export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses] + +export type TuiShowToastData = { + body?: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/show-toast" +} + +export type TuiShowToastErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiShowToastError = TuiShowToastErrors[keyof TuiShowToastErrors] + +export type TuiShowToastResponses = { + /** + * Toast notification shown successfully + */ + 200: boolean +} + +export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] + +export type TuiPublishData = { + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/publish" +} + +export type TuiPublishErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors] + +export type TuiPublishResponses = { + /** + * Event published successfully + */ + 200: boolean +} + +export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses] + +export type TuiSelectSessionData = { + body?: { + /** + * Session ID to navigate to + */ + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/select-session" +} + +export type TuiSelectSessionErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type TuiSelectSessionError = TuiSelectSessionErrors[keyof TuiSelectSessionErrors] + +export type TuiSelectSessionResponses = { + /** + * Session selected successfully + */ + 200: boolean +} + +export type TuiSelectSessionResponse = TuiSelectSessionResponses[keyof TuiSelectSessionResponses] + +export type TuiControlNextData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/control/next" +} + +export type TuiControlNextErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiControlNextError = TuiControlNextErrors[keyof TuiControlNextErrors] + +export type TuiControlNextResponses = { + /** + * Next TUI request + */ + 200: { + path: string + body: unknown + } +} + +export type TuiControlNextResponse = TuiControlNextResponses[keyof TuiControlNextResponses] + +export type TuiControlResponseData = { + body?: unknown + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/control/response" +} + +export type TuiControlResponseErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiControlResponseError = TuiControlResponseErrors[keyof TuiControlResponseErrors] + +export type TuiControlResponseResponses = { + /** + * Response submitted successfully + */ + 200: boolean +} + +export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] + +export type ExperimentalWorkspaceAdapterListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/adapter" +} + +export type ExperimentalWorkspaceAdapterListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceAdapterListError = + ExperimentalWorkspaceAdapterListErrors[keyof ExperimentalWorkspaceAdapterListErrors] + +export type ExperimentalWorkspaceAdapterListResponses = { + /** + * Workspace adapters + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] + +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceListError = ExperimentalWorkspaceListErrors[keyof ExperimentalWorkspaceListErrors] + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + id?: string + type: string + branch?: string | null + extra?: unknown | null + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceCreateErrors = { + /** + * WorkspaceCreateError | BadRequest | InvalidRequestError + */ + 400: WorkspaceCreateError | EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + +export type ExperimentalWorkspaceCreateResponses = { + /** + * Workspace created + */ + 200: Workspace +} + +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + +export type ExperimentalWorkspaceSyncListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/sync-list" +} + +export type ExperimentalWorkspaceSyncListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceSyncListError = + ExperimentalWorkspaceSyncListErrors[keyof ExperimentalWorkspaceSyncListErrors] + +export type ExperimentalWorkspaceSyncListResponses = { + /** + * Workspace list synced + */ + 204: void +} + +export type ExperimentalWorkspaceSyncListResponse = + ExperimentalWorkspaceSyncListResponses[keyof ExperimentalWorkspaceSyncListResponses] + +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceStatusError = + ExperimentalWorkspaceStatusErrors[keyof ExperimentalWorkspaceStatusErrors] + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + +export type ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceRemoveErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + +export type ExperimentalWorkspaceRemoveResponses = { + /** + * Workspace removed + */ + 200: Workspace +} + +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + +export type ExperimentalWorkspaceWarpData = { + body?: { + id: string | null + sessionID: string + copyChanges?: boolean + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/warp" +} + +export type ExperimentalWorkspaceWarpErrors = { + /** + * WorkspaceWarpError | VcsApplyError | InvalidRequestError + */ + 400: WorkspaceWarpError | VcsApplyError | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] + +export type ExperimentalWorkspaceWarpResponses = { + /** + * Session warped + */ + 204: void +} + +export type ExperimentalWorkspaceWarpResponse = + ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses] + +export type V2HealthGetData = { + body?: never + path?: never + query?: never + url: "/api/health" +} + +export type V2HealthGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors] + +export type V2HealthGetResponses = { + /** + * Success + */ + 200: { + healthy: true + } +} + +export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses] + +export type V2LocationGetData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/location" +} + +export type V2LocationGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2LocationGetError = V2LocationGetErrors[keyof V2LocationGetErrors] + +export type V2LocationGetResponses = { + /** + * Location.Info + */ + 200: LocationInfo +} + +export type V2LocationGetResponse = V2LocationGetResponses[keyof V2LocationGetResponses] + +export type V2AgentListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/agent" +} + +export type V2AgentListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2AgentListError = V2AgentListErrors[keyof V2AgentListErrors] + +export type V2AgentListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2AgentListResponse = V2AgentListResponses[keyof V2AgentListResponses] + +export type V2SessionListData = { + body?: never + path?: never + query?: { + workspace?: string + limit?: number + order?: "asc" | "desc" + search?: string + directory?: string + project?: string + subpath?: string + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. + */ + cursor?: string + } + url: "/api/session" +} + +export type V2SessionListErrors = { + /** + * InvalidCursorError | InvalidRequestError + */ + 400: InvalidCursorError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] + +export type V2SessionListResponses = { + /** + * SessionsResponse + */ + 200: SessionsResponse +} + +export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] + +export type V2SessionCreateData = { + body: { + id?: string + agent?: string + model?: ModelRef + location?: LocationRef + } + path?: never + query?: never + url: "/api/session" +} + +export type V2SessionCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SessionCreateError = V2SessionCreateErrors[keyof V2SessionCreateErrors] + +export type V2SessionCreateResponses = { + /** + * Success + */ + 200: { + data: SessionV2Info + } +} + +export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses] + +export type V2SessionActiveData = { + body?: never + path?: never + query?: never + url: "/api/session/active" +} + +export type V2SessionActiveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SessionActiveError = V2SessionActiveErrors[keyof V2SessionActiveErrors] + +export type V2SessionActiveResponses = { + /** + * Success + */ + 200: { + data: { + [key: string]: unknown | SessionActive + } + } +} + +export type V2SessionActiveResponse = V2SessionActiveResponses[keyof V2SessionActiveResponses] + +export type V2SessionGetData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}" +} + +export type V2SessionGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionGetError = V2SessionGetErrors[keyof V2SessionGetErrors] + +export type V2SessionGetResponses = { + /** + * Success + */ + 200: { + data: SessionV2Info + } +} + +export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses] + +export type V2SessionSwitchAgentData = { + body: { + agent: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/agent" +} + +export type V2SessionSwitchAgentErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors] + +export type V2SessionSwitchAgentResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V2SessionSwitchAgentResponses] + +export type V2SessionSwitchModelData = { + body: { + model: ModelRef + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/model" +} + +export type V2SessionSwitchModelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors] + +export type V2SessionSwitchModelResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses] + +export type V2SessionPromptData = { + body: { + id?: string + prompt: PromptInput + delivery?: "steer" | "queue" + resume?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/prompt" +} + +export type V2SessionPromptErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * ConflictError + */ + 409: ConflictError +} + +export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] + +export type V2SessionPromptResponses = { + /** + * Success + */ + 200: { + data: SessionInputAdmitted + } +} + +export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] + +export type V2SessionCompactData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/compact" +} + +export type V2SessionCompactErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] + +export type V2SessionCompactResponses = { + /** + * + */ + 204: void +} + +export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] + +export type V2SessionWaitData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/wait" +} + +export type V2SessionWaitErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2SessionWaitError = V2SessionWaitErrors[keyof V2SessionWaitErrors] + +export type V2SessionWaitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] + +export type V2SessionRevertStageData = { + body: { + messageID: string + files?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/stage" +} + +export type V2SessionRevertStageErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * MessageNotFoundError | SessionNotFoundError + */ + 404: MessageNotFoundError | SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionRevertStageError = V2SessionRevertStageErrors[keyof V2SessionRevertStageErrors] + +export type V2SessionRevertStageResponses = { + /** + * Success + */ + 200: { + data: RevertState + } +} + +export type V2SessionRevertStageResponse = V2SessionRevertStageResponses[keyof V2SessionRevertStageResponses] + +export type V2SessionRevertClearData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/clear" +} + +export type V2SessionRevertClearErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionRevertClearError = V2SessionRevertClearErrors[keyof V2SessionRevertClearErrors] + +export type V2SessionRevertClearResponses = { + /** + * + */ + 204: void +} + +export type V2SessionRevertClearResponse = V2SessionRevertClearResponses[keyof V2SessionRevertClearResponses] + +export type V2SessionRevertCommitData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/commit" +} + +export type V2SessionRevertCommitErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors] + +export type V2SessionRevertCommitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionRevertCommitResponse = V2SessionRevertCommitResponses[keyof V2SessionRevertCommitResponses] + +export type V2SessionContextData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/context" +} + +export type V2SessionContextErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionContextError = V2SessionContextErrors[keyof V2SessionContextErrors] + +export type V2SessionContextResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] + +export type V2SessionHistoryData = { + body?: never + path: { + sessionID: string + } + query?: { + limit?: number + after?: number + } + url: "/api/session/{sessionID}/history" +} + +export type V2SessionHistoryErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionHistoryError = V2SessionHistoryErrors[keyof V2SessionHistoryErrors] + +export type V2SessionHistoryResponses = { + /** + * SessionHistory + */ + 200: SessionHistory +} + +export type V2SessionHistoryResponse = V2SessionHistoryResponses[keyof V2SessionHistoryResponses] + +export type V2SessionEventsData = { + body?: never + path: { + sessionID: string + } + query?: { + after?: string + } + url: "/api/session/{sessionID}/event" +} + +export type V2SessionEventsErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionEventsError = V2SessionEventsErrors[keyof V2SessionEventsErrors] + +export type V2SessionEventsResponses = { + /** + * Success + */ + 200: { + id: string + event: string + data: SessionDurableEventStream + } +} + +export type V2SessionEventsResponse = V2SessionEventsResponses[keyof V2SessionEventsResponses] + +export type V2SessionInterruptData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/interrupt" +} + +export type V2SessionInterruptErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionInterruptError = V2SessionInterruptErrors[keyof V2SessionInterruptErrors] + +export type V2SessionInterruptResponses = { + /** + * + */ + 204: void +} + +export type V2SessionInterruptResponse = V2SessionInterruptResponses[keyof V2SessionInterruptResponses] + +export type V2SessionMessageData = { + body?: never + path: { + sessionID: string + messageID: string + } + query?: never + url: "/api/session/{sessionID}/message/{messageID}" +} + +export type V2SessionMessageErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | MessageNotFoundError + */ + 404: MessageNotFoundError | SessionNotFoundError +} + +export type V2SessionMessageError = V2SessionMessageErrors[keyof V2SessionMessageErrors] + +export type V2SessionMessageResponses = { + /** + * Success + */ + 200: { + data: SessionMessage + } +} + +export type V2SessionMessageResponse = V2SessionMessageResponses[keyof V2SessionMessageResponses] + +export type V2SessionMessagesData = { + body?: never + path: { + sessionID: string + } + query?: { + limit?: number + order?: "asc" | "desc" + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. + */ + cursor?: string + } + url: "/api/session/{sessionID}/message" +} + +export type V2SessionMessagesErrors = { + /** + * InvalidCursorError | InvalidRequestError + */ + 400: InvalidCursorError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] + +export type V2SessionMessagesResponses = { + /** + * SessionMessagesResponse + */ + 200: SessionMessagesResponse +} + +export type V2SessionMessagesResponse = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] + +export type V2ModelListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/model" +} + +export type V2ModelListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2ModelListError = V2ModelListErrors[keyof V2ModelListErrors] + +export type V2ModelListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] + +export type V2ProviderListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/provider" +} + +export type V2ProviderListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2ProviderListError = V2ProviderListErrors[keyof V2ProviderListErrors] + +export type V2ProviderListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2ProviderListResponse = V2ProviderListResponses[keyof V2ProviderListResponses] + +export type V2ProviderGetData = { + body?: never + path: { + providerID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/provider/{providerID}" +} + +export type V2ProviderGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ProviderNotFoundError + */ + 404: ProviderNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2ProviderGetError = V2ProviderGetErrors[keyof V2ProviderGetErrors] + +export type V2ProviderGetResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: ProviderV2Info + } +} + +export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] + +export type V2IntegrationListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/integration" +} + +export type V2IntegrationListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationListError = V2IntegrationListErrors[keyof V2IntegrationListErrors] + +export type V2IntegrationListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2IntegrationListResponse = V2IntegrationListResponses[keyof V2IntegrationListResponses] + +export type V2IntegrationGetData = { + body?: never + path: { + integrationID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/integration/{integrationID}" +} + +export type V2IntegrationGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationGetError = V2IntegrationGetErrors[keyof V2IntegrationGetErrors] + +export type V2IntegrationGetResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: IntegrationInfo + } +} + +export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses] + +export type V2IntegrationConnectKeyData = { + body: { + key: string + label?: string + } + path: { + integrationID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/integration/{integrationID}/connect/key" +} + +export type V2IntegrationConnectKeyErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationConnectKeyError = V2IntegrationConnectKeyErrors[keyof V2IntegrationConnectKeyErrors] + +export type V2IntegrationConnectKeyResponses = { + /** + * + */ + 204: void +} + +export type V2IntegrationConnectKeyResponse = V2IntegrationConnectKeyResponses[keyof V2IntegrationConnectKeyResponses] + +export type V2IntegrationConnectOauthData = { + body: { + methodID: string + inputs: { + [key: string]: string + } + label?: string + } + path: { + integrationID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/integration/{integrationID}/connect/oauth" +} + +export type V2IntegrationConnectOauthErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationConnectOauthError = V2IntegrationConnectOauthErrors[keyof V2IntegrationConnectOauthErrors] + +export type V2IntegrationConnectOauthResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: IntegrationAttempt + } +} + +export type V2IntegrationConnectOauthResponse = + V2IntegrationConnectOauthResponses[keyof V2IntegrationConnectOauthResponses] + +export type V2IntegrationAttemptCancelData = { + body?: never + path: { + attemptID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/integration/attempt/{attemptID}" +} + +export type V2IntegrationAttemptCancelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationAttemptCancelError = V2IntegrationAttemptCancelErrors[keyof V2IntegrationAttemptCancelErrors] + +export type V2IntegrationAttemptCancelResponses = { + /** + * + */ + 204: void +} + +export type V2IntegrationAttemptCancelResponse = + V2IntegrationAttemptCancelResponses[keyof V2IntegrationAttemptCancelResponses] + +export type V2IntegrationAttemptStatusData = { + body?: never + path: { + attemptID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/integration/attempt/{attemptID}" +} + +export type V2IntegrationAttemptStatusErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationAttemptStatusError = V2IntegrationAttemptStatusErrors[keyof V2IntegrationAttemptStatusErrors] + +export type V2IntegrationAttemptStatusResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: IntegrationAttemptStatus + } +} + +export type V2IntegrationAttemptStatusResponse = + V2IntegrationAttemptStatusResponses[keyof V2IntegrationAttemptStatusResponses] + +export type V2IntegrationAttemptCompleteData = { + body: { + code?: string + } + path: { + attemptID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/integration/attempt/{attemptID}/complete" +} + +export type V2IntegrationAttemptCompleteErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationAttemptCompleteError = + V2IntegrationAttemptCompleteErrors[keyof V2IntegrationAttemptCompleteErrors] + +export type V2IntegrationAttemptCompleteResponses = { + /** + * + */ + 204: void +} + +export type V2IntegrationAttemptCompleteResponse = + V2IntegrationAttemptCompleteResponses[keyof V2IntegrationAttemptCompleteResponses] + +export type V2CredentialRemoveData = { + body?: never + path: { + credentialID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/credential/{credentialID}" +} + +export type V2CredentialRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2CredentialRemoveError = V2CredentialRemoveErrors[keyof V2CredentialRemoveErrors] + +export type V2CredentialRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2CredentialRemoveResponse = V2CredentialRemoveResponses[keyof V2CredentialRemoveResponses] + +export type V2CredentialUpdateData = { + body: { + label: string + } + path: { + credentialID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/credential/{credentialID}" +} + +export type V2CredentialUpdateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2CredentialUpdateError = V2CredentialUpdateErrors[keyof V2CredentialUpdateErrors] + +export type V2CredentialUpdateResponses = { + /** + * + */ + 204: void +} + +export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses] + +export type V2PermissionRequestListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/permission/request" +} + +export type V2PermissionRequestListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors] + +export type V2PermissionRequestListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2PermissionRequestListResponse = V2PermissionRequestListResponses[keyof V2PermissionRequestListResponses] + +export type V2PermissionSavedListData = { + body?: never + path?: never + query?: { + projectID?: string + } + url: "/api/permission/saved" +} + +export type V2PermissionSavedListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors] + +export type V2PermissionSavedListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2PermissionSavedListResponse = V2PermissionSavedListResponses[keyof V2PermissionSavedListResponses] + +export type V2PermissionSavedRemoveData = { + body?: never + path: { + id: string + } + query?: never + url: "/api/permission/saved/{id}" +} + +export type V2PermissionSavedRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors] + +export type V2PermissionSavedRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2PermissionSavedRemoveResponse = V2PermissionSavedRemoveResponses[keyof V2PermissionSavedRemoveResponses] + +export type V2SessionPermissionListData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/permission" +} + +export type V2SessionPermissionListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors] + +export type V2SessionPermissionListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses] + +export type V2SessionPermissionCreateData = { + body: { + id?: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + agent?: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/permission" +} + +export type V2SessionPermissionCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionPermissionCreateError = V2SessionPermissionCreateErrors[keyof V2SessionPermissionCreateErrors] + +export type V2SessionPermissionCreateResponses = { + /** + * Success + */ + 200: { + data: { + id: string + effect: PermissionV2Effect + } + } +} + +export type V2SessionPermissionCreateResponse = + V2SessionPermissionCreateResponses[keyof V2SessionPermissionCreateResponses] + +export type V2SessionPermissionGetData = { + body?: never + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/permission/{requestID}" +} + +export type V2SessionPermissionGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | PermissionNotFoundError + */ + 404: PermissionNotFoundError | SessionNotFoundError +} + +export type V2SessionPermissionGetError = V2SessionPermissionGetErrors[keyof V2SessionPermissionGetErrors] + +export type V2SessionPermissionGetResponses = { + /** + * Success + */ + 200: { + data: PermissionV2Request + } +} + +export type V2SessionPermissionGetResponse = V2SessionPermissionGetResponses[keyof V2SessionPermissionGetResponses] + +export type V2SessionPermissionReplyData = { + body: { + reply: PermissionV2Reply + message?: string + } + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/permission/{requestID}/reply" +} + +export type V2SessionPermissionReplyErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | PermissionNotFoundError + */ + 404: PermissionNotFoundError | SessionNotFoundError +} + +export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors] + +export type V2SessionPermissionReplyResponses = { + /** + * + */ + 204: void +} + +export type V2SessionPermissionReplyResponse = + V2SessionPermissionReplyResponses[keyof V2SessionPermissionReplyResponses] + +export type V2FsReadData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/fs/read/*" +} + +export type V2FsReadErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2FsReadError = V2FsReadErrors[keyof V2FsReadErrors] + +export type V2FsReadResponses = { + /** + * Success + */ + 200: Blob | File +} + +export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses] + +export type V2FsListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + path?: string + } + url: "/api/fs/list" +} + +export type V2FsListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2FsListError = V2FsListErrors[keyof V2FsListErrors] + +export type V2FsListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2FsListResponse = V2FsListResponses[keyof V2FsListResponses] + +export type V2FsFindData = { + body?: never + path?: never + query: { + location?: { + directory?: string + workspace?: string + } + query: string + type?: "file" | "directory" + limit?: string + } + url: "/api/fs/find" +} + +export type V2FsFindErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2FsFindError = V2FsFindErrors[keyof V2FsFindErrors] + +export type V2FsFindResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2FsFindResponse = V2FsFindResponses[keyof V2FsFindResponses] + +export type V2CommandListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/command" +} + +export type V2CommandListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2CommandListError = V2CommandListErrors[keyof V2CommandListErrors] + +export type V2CommandListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2CommandListResponse = V2CommandListResponses[keyof V2CommandListResponses] + +export type V2SkillListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/skill" +} + +export type V2SkillListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SkillListError = V2SkillListErrors[keyof V2SkillListErrors] + +export type V2SkillListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2SkillListResponse = V2SkillListResponses[keyof V2SkillListResponses] + +export type V2EventSubscribeData = { + body?: never + path?: never + query?: never + url: "/api/event" +} + +export type V2EventSubscribeErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2EventSubscribeError = V2EventSubscribeErrors[keyof V2EventSubscribeErrors] + +export type V2EventSubscribeResponses = { + /** + * Event stream + */ + 200: V2Event +} + +export type V2EventSubscribeResponse = V2EventSubscribeResponses[keyof V2EventSubscribeResponses] + +export type V2PtyListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty" +} + +export type V2PtyListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PtyListError = V2PtyListErrors[keyof V2PtyListErrors] + +export type V2PtyListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2PtyListResponse = V2PtyListResponses[keyof V2PtyListResponses] + +export type V2PtyCreateData = { + body: { + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + } + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty" +} + +export type V2PtyCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PtyCreateError = V2PtyCreateErrors[keyof V2PtyCreateErrors] + +export type V2PtyCreateResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Pty + } +} + +export type V2PtyCreateResponse = V2PtyCreateResponses[keyof V2PtyCreateResponses] + +export type V2PtyRemoveData = { + body?: never + path: { + ptyID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty/{ptyID}" +} + +export type V2PtyRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyRemoveError = V2PtyRemoveErrors[keyof V2PtyRemoveErrors] + +export type V2PtyRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2PtyRemoveResponse = V2PtyRemoveResponses[keyof V2PtyRemoveResponses] + +export type V2PtyGetData = { + body?: never + path: { + ptyID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty/{ptyID}" +} + +export type V2PtyGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyGetError = V2PtyGetErrors[keyof V2PtyGetErrors] + +export type V2PtyGetResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Pty + } +} + +export type V2PtyGetResponse = V2PtyGetResponses[keyof V2PtyGetResponses] + +export type V2PtyUpdateData = { + body: { + title?: string + size?: { + rows: number + cols: number + } + } + path: { + ptyID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty/{ptyID}" +} + +export type V2PtyUpdateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyUpdateError = V2PtyUpdateErrors[keyof V2PtyUpdateErrors] + +export type V2PtyUpdateResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Pty + } +} + +export type V2PtyUpdateResponse = V2PtyUpdateResponses[keyof V2PtyUpdateResponses] + +export type V2PtyConnectTokenData = { + body?: never + path: { + ptyID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty/{ptyID}/connect-token" +} + +export type V2PtyConnectTokenErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ForbiddenError + */ + 403: ForbiddenError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyConnectTokenError = V2PtyConnectTokenErrors[keyof V2PtyConnectTokenErrors] + +export type V2PtyConnectTokenResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: PtyTicketConnectToken + } +} + +export type V2PtyConnectTokenResponse = V2PtyConnectTokenResponses[keyof V2PtyConnectTokenResponses] + +export type V2PtyConnectData = { + body?: never + path: { + ptyID: string + } + query?: { + "location[directory]"?: string + "location[workspace]"?: string + cursor?: string + ticket?: string + } + url: "/api/pty/{ptyID}/connect" +} + +export type V2PtyConnectErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ForbiddenError + */ + 403: ForbiddenError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyConnectError = V2PtyConnectErrors[keyof V2PtyConnectErrors] + +export type V2PtyConnectResponses = { + /** + * Success + */ + 200: boolean +} + +export type V2PtyConnectResponse = V2PtyConnectResponses[keyof V2PtyConnectResponses] + +export type V2QuestionRequestListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/question/request" +} + +export type V2QuestionRequestListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2QuestionRequestListError = V2QuestionRequestListErrors[keyof V2QuestionRequestListErrors] + +export type V2QuestionRequestListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2QuestionRequestListResponse = V2QuestionRequestListResponses[keyof V2QuestionRequestListResponses] + +export type V2SessionQuestionListData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/question" +} + +export type V2SessionQuestionListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionQuestionListError = V2SessionQuestionListErrors[keyof V2SessionQuestionListErrors] + +export type V2SessionQuestionListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2SessionQuestionListResponse = V2SessionQuestionListResponses[keyof V2SessionQuestionListResponses] + +export type V2SessionQuestionReplyData = { + body: QuestionV2Reply + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/question/{requestID}/reply" +} + +export type V2SessionQuestionReplyErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | QuestionNotFoundError + */ + 404: QuestionNotFoundError | SessionNotFoundError +} + +export type V2SessionQuestionReplyError = V2SessionQuestionReplyErrors[keyof V2SessionQuestionReplyErrors] + +export type V2SessionQuestionReplyResponses = { + /** + * + */ + 204: void +} + +export type V2SessionQuestionReplyResponse = V2SessionQuestionReplyResponses[keyof V2SessionQuestionReplyResponses] + +export type V2SessionQuestionRejectData = { + body?: never + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/question/{requestID}/reject" +} + +export type V2SessionQuestionRejectErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | QuestionNotFoundError + */ + 404: QuestionNotFoundError | SessionNotFoundError +} + +export type V2SessionQuestionRejectError = V2SessionQuestionRejectErrors[keyof V2SessionQuestionRejectErrors] + +export type V2SessionQuestionRejectResponses = { + /** + * + */ + 204: void +} + +export type V2SessionQuestionRejectResponse = V2SessionQuestionRejectResponses[keyof V2SessionQuestionRejectResponses] + +export type V2ReferenceListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/reference" +} + +export type V2ReferenceListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ReferenceListError = V2ReferenceListErrors[keyof V2ReferenceListErrors] + +export type V2ReferenceListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2ReferenceListResponse = V2ReferenceListResponses[keyof V2ReferenceListResponses] + +export type V2ProjectCopyRemoveData = { + body?: { + directory: string + force: boolean + } + path: { + projectID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/experimental/project/{projectID}/copy" +} + +export type V2ProjectCopyRemoveErrors = { + /** + * ProjectCopyError | InvalidRequestError + */ + 400: ProjectCopyError | InvalidRequestError +} + +export type V2ProjectCopyRemoveError = V2ProjectCopyRemoveErrors[keyof V2ProjectCopyRemoveErrors] + +export type V2ProjectCopyRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2ProjectCopyRemoveResponse = V2ProjectCopyRemoveResponses[keyof V2ProjectCopyRemoveResponses] + +export type V2ProjectCopyCreateData = { + body?: { + strategy: string + directory: string + name?: string + } + path: { + projectID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/experimental/project/{projectID}/copy" +} + +export type V2ProjectCopyCreateErrors = { + /** + * ProjectCopyError | InvalidRequestError + */ + 400: ProjectCopyError | InvalidRequestError +} + +export type V2ProjectCopyCreateError = V2ProjectCopyCreateErrors[keyof V2ProjectCopyCreateErrors] + +export type V2ProjectCopyCreateResponses = { + /** + * ProjectCopy.Copy + */ + 200: ProjectCopyCopy +} + +export type V2ProjectCopyCreateResponse = V2ProjectCopyCreateResponses[keyof V2ProjectCopyCreateResponses] + +export type V2ProjectCopyRefreshData = { + body?: never + path: { + projectID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/experimental/project/{projectID}/copy/refresh" +} + +export type V2ProjectCopyRefreshErrors = { + /** + * ProjectCopyError | InvalidRequestError + */ + 400: ProjectCopyError | InvalidRequestError +} + +export type V2ProjectCopyRefreshError = V2ProjectCopyRefreshErrors[keyof V2ProjectCopyRefreshErrors] + +export type V2ProjectCopyRefreshResponses = { + /** + * + */ + 204: void +} + +export type V2ProjectCopyRefreshResponse = V2ProjectCopyRefreshResponses[keyof V2ProjectCopyRefreshResponses] + +export type PtyConnectData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + cursor?: string + ticket?: string + } + url: "/pty/{ptyID}/connect" +} + +export type PtyConnectErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] + +export type PtyConnectResponses = { + /** + * Connected session + */ + 200: boolean +} + +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9615eacc7abe43ac2f775bdb45ce71287271d84e --- /dev/null +++ b/packages/sdk/js/src/v2/index.ts @@ -0,0 +1,23 @@ +export * from "./client.js" +export * from "./server.js" + +import { createOpencodeClient } from "./client.js" +import { createOpencodeServer } from "./server.js" +import type { ServerOptions } from "./server.js" + +export * as data from "./data.js" + +export async function createOpencode(options?: ServerOptions) { + const server = await createOpencodeServer({ + ...options, + }) + + const client = createOpencodeClient({ + baseUrl: server.url, + }) + + return { + client, + server, + } +} diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..48f1a253da8d9d9e93c07ced8a61863591514581 --- /dev/null +++ b/packages/sdk/js/src/v2/server.ts @@ -0,0 +1,134 @@ +import launch from "cross-spawn" +import { type Config } from "./gen/types.gen.js" +import { stop, bindAbort } from "../process.js" + +export type ServerOptions = { + hostname?: string + port?: number + signal?: AbortSignal + timeout?: number + config?: Config +} + +export type TuiOptions = { + project?: string + model?: string + session?: string + agent?: string + signal?: AbortSignal + config?: Config +} + +export async function createOpencodeServer(options?: ServerOptions) { + options = Object.assign( + { + hostname: "127.0.0.1", + port: 4096, + timeout: 5000, + }, + options ?? {}, + ) + + const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] + if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) + + const proc = launch(`opencode`, args, { + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), + }, + }) + let clear = () => {} + + const url = await new Promise((resolve, reject) => { + const id = setTimeout(() => { + clear() + stop(proc) + reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`)) + }, options.timeout) + let output = "" + let resolved = false + proc.stdout?.on("data", (chunk) => { + if (resolved) return + output += chunk.toString() + const lines = output.split("\n") + for (const line of lines) { + if (line.startsWith("opencode server listening")) { + const match = line.match(/on\s+(https?:\/\/[^\s]+)/) + if (!match) { + clear() + stop(proc) + clearTimeout(id) + reject(new Error(`Failed to parse server url from output: ${line}`)) + return + } + clearTimeout(id) + resolved = true + resolve(match[1]!) + return + } + } + }) + proc.stderr?.on("data", (chunk) => { + output += chunk.toString() + }) + proc.on("exit", (code) => { + clearTimeout(id) + let msg = `Server exited with code ${code}` + if (output.trim()) { + msg += `\nServer output: ${output}` + } + reject(new Error(msg)) + }) + proc.on("error", (error) => { + clearTimeout(id) + reject(error) + }) + clear = bindAbort(proc, options.signal, () => { + clearTimeout(id) + reject(options.signal?.reason) + }) + }) + + return { + url, + close() { + clear() + stop(proc) + }, + } +} + +export function createOpencodeTui(options?: TuiOptions) { + const args = [] + + if (options?.project) { + args.push(`--project=${options.project}`) + } + if (options?.model) { + args.push(`--model=${options.model}`) + } + if (options?.session) { + args.push(`--session=${options.session}`) + } + if (options?.agent) { + args.push(`--agent=${options.agent}`) + } + + const proc = launch(`opencode`, args, { + stdio: "inherit", + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}), + }, + }) + + const clear = bindAbort(proc, options?.signal) + + return { + close() { + clear() + stop(proc) + }, + } +} diff --git a/packages/sdk/js/sst-env.d.ts b/packages/sdk/js/sst-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..301538ccb214de2411432865e72147a1e4121ca7 --- /dev/null +++ b/packages/sdk/js/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/sdk/js/test/session-history.test.ts b/packages/sdk/js/test/session-history.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..44a974333168d9741671992b9880d7b076a2ba43 --- /dev/null +++ b/packages/sdk/js/test/session-history.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import type { V2SessionHistoryData } from "../src/v2/gen/types.gen" + +test("uses numeric Session history positions", () => { + const input = { + path: { sessionID: "ses_test" }, + query: { after: 1, limit: 50 }, + url: "/api/session/{sessionID}/history", + } satisfies V2SessionHistoryData + + expect(input.query.after).toBe(1) +}) diff --git a/packages/sdk/js/tsconfig.json b/packages/sdk/js/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..3ab5fcb7689048db7d479ef297926fbae66c0d70 --- /dev/null +++ b/packages/sdk/js/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "module": "nodenext", + "declaration": true, + "moduleResolution": "nodenext", + "lib": ["es2022", "dom", "dom.iterable"], + "composite": true, + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..981ad28db93d253ac02e231b6dbc28f034fe5c35 --- /dev/null +++ b/packages/server/src/api.ts @@ -0,0 +1,8 @@ +import { makeDefaultApi } from "@opencode-ai/protocol/api" +import { LocationMiddleware } from "./location" +import { SessionLocationMiddleware } from "./middleware/session-location" + +export const Api = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..c71536518d74e80d802a1be40fd3517cc634cf5d --- /dev/null +++ b/packages/server/src/auth.ts @@ -0,0 +1,63 @@ +export * as ServerAuth from "./auth" + +import { Config as EffectConfig, Context, Effect, Layer, Option, Redacted } from "effect" + +export type Credentials = { + password?: string + username?: string +} + +export type DecodedCredentials = { + readonly username: string + readonly password: Redacted.Redacted +} + +export type Info = { + readonly password: Option.Option + readonly username: string +} + +export class Config extends Context.Service()("@opencode/ServerAuthConfig") { + static configLayer(input: Info) { + return Layer.succeed(this, this.of(input)) + } + + static get layer() { + return Layer.effect( + this, + Effect.gen(function* () { + return Config.of( + yield* EffectConfig.all({ + password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option), + username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), + }), + ) + }), + ) + } +} + +export function required(config: Info) { + return Option.isSome(config.password) && config.password.value !== "" +} + +export function authorized(credentials: DecodedCredentials, config: Info) { + return ( + Option.isSome(config.password) && + credentials.username === config.username && + Redacted.value(credentials.password) === config.password.value + ) +} + +export function header(credentials?: Credentials) { + const password = credentials?.password ?? process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + + return `Basic ${Buffer.from(`${credentials?.username ?? process.env.OPENCODE_SERVER_USERNAME ?? "opencode"}:${password}`).toString("base64")}` +} + +export function headers(credentials?: Credentials) { + const authorization = header(credentials) + if (!authorization) return undefined + return { Authorization: authorization } +} diff --git a/packages/server/src/cors.ts b/packages/server/src/cors.ts new file mode 100644 index 0000000000000000000000000000000000000000..92296a3b7dbf24f001eadf93af795b898825c826 --- /dev/null +++ b/packages/server/src/cors.ts @@ -0,0 +1,34 @@ +import { Context } from "effect" + +const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ + +export type CorsOptions = { readonly cors?: ReadonlyArray } + +export const CorsConfig = Context.Reference("@opencode/ServerCorsConfig", { + defaultValue: () => undefined, +}) + +export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { + if (!input) return true + if (input.startsWith("http://localhost:")) return true + if (input.startsWith("http://127.0.0.1:")) return true + if (input.startsWith("oc://renderer")) return true + if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") + return true + if (opencodeOrigin.test(input)) return true + return opts?.cors?.includes(input) ?? false +} + +export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) { + if (!input) return true + if (host && sameHost(input, host)) return true + return isAllowedCorsOrigin(input, opts) +} + +function sameHost(origin: string, host: string) { + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a5e2e777e22d7cb95484b9d211c2b81808cd675 --- /dev/null +++ b/packages/server/src/handlers.ts @@ -0,0 +1,40 @@ +import { Layer } from "effect" +import { MessageHandler } from "./handlers/message" +import { ModelHandler } from "./handlers/model" +import { ProviderHandler } from "./handlers/provider" +import { SessionHandler } from "./handlers/session" +import { PermissionHandler } from "./handlers/permission" +import { FileSystemHandler } from "./handlers/fs" +import { CommandHandler } from "./handlers/command" +import { SkillHandler } from "./handlers/skill" +import { EventHandler } from "./handlers/event" +import { AgentHandler } from "./handlers/agent" +import { HealthHandler } from "./handlers/health" +import { PtyHandler } from "./handlers/pty" +import { QuestionHandler } from "./handlers/question" +import { ReferenceHandler } from "./handlers/reference" +import { LocationHandler } from "./handlers/location" +import { IntegrationHandler } from "./handlers/integration" +import { CredentialHandler } from "./handlers/credential" +import { ProjectCopyHandler } from "./handlers/project-copy" + +export const handlers = Layer.mergeAll( + HealthHandler, + LocationHandler, + AgentHandler, + SessionHandler, + MessageHandler, + ModelHandler, + ProviderHandler, + IntegrationHandler, + CredentialHandler, + PermissionHandler, + FileSystemHandler, + CommandHandler, + SkillHandler, + EventHandler, + PtyHandler, + QuestionHandler, + ReferenceHandler, + ProjectCopyHandler, +) diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1511e3c62cdefab6e73fcc1454c468f1710a0d7 --- /dev/null +++ b/packages/server/src/handlers/agent.ts @@ -0,0 +1,13 @@ +import { AgentV2 } from "@opencode-ai/core/agent" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => + handlers.handle("agent.list", () => + Effect.gen(function* () { + return yield* response(AgentV2.Service.use((agent) => agent.all())) + }), + ), +) diff --git a/packages/server/src/handlers/command.ts b/packages/server/src/handlers/command.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf41e79f835bd5d5ee11d5fd5b5b284f0f862375 --- /dev/null +++ b/packages/server/src/handlers/command.ts @@ -0,0 +1,8 @@ +import { CommandV2 } from "@opencode-ai/core/command" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) => + handlers.handle("command.list", () => response(CommandV2.Service.use((command) => command.list()))), +) diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e138a5d5a2015ca898d7d7f5058a5f4d2bebc9c --- /dev/null +++ b/packages/server/src/handlers/credential.ts @@ -0,0 +1,22 @@ +import { Integration } from "@opencode-ai/core/integration" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" + +export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", (handlers) => + handlers + .handle( + "credential.update", + Effect.fn(function* (ctx) { + yield* (yield* Integration.Service).connection.update(ctx.params.credentialID, { label: ctx.payload.label }) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "credential.remove", + Effect.fn(function* (ctx) { + yield* (yield* Integration.Service).connection.remove(ctx.params.credentialID) + return HttpApiSchema.NoContent.make() + }), + ), +) diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts new file mode 100644 index 0000000000000000000000000000000000000000..47e94731ee081f77cd5f6d38165899c8f0cff8be --- /dev/null +++ b/packages/server/src/handlers/event.ts @@ -0,0 +1,52 @@ +import { EventV2 } from "@opencode-ai/core/event" +import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +import { Effect, Schema, Stream } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import * as Sse from "effect/unstable/encoding/Sse" +import { Api } from "../api" + +const subscriberCapacity = 256 + +function eventData(data: unknown): Sse.Event { + return { + _tag: "Event", + event: "message", + id: undefined, + data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)), + } +} + +export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) => + Effect.gen(function* () { + const events = yield* EventV2.Service + return handlers.handleRaw("event.subscribe", () => + Effect.gen(function* () { + const connected = { + id: EventV2.ID.create(), + type: "server.connected", + data: {}, + } + const output = Stream.unwrap( + Effect.gen(function* () { + // Acquiring the bounded stream installs its listener before readiness is observable. + const live = yield* EventV2.allBounded(events, subscriberCapacity) + return Stream.make(connected).pipe(Stream.concat(live)) + }), + ).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode())) + const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n")) + return HttpServerResponse.stream( + output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) + }), + ) + }), +) diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7d1d43bab7a089df575f707f57ba2a0f6b589f0 --- /dev/null +++ b/packages/server/src/handlers/fs.ts @@ -0,0 +1,39 @@ +import { FileSystem } from "@opencode-ai/core/filesystem" +import { RelativePath } from "@opencode-ai/core/schema" +import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) => + Effect.gen(function* () { + return handlers + .handleRaw("fs.read", (ctx) => + Effect.gen(function* () { + const file = yield* (yield* FileSystem.Service).read({ + path: RelativePath.make( + decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)), + ), + }) + return HttpServerResponse.uint8Array(file.content, { contentType: file.mime }) + }), + ) + .handle("fs.list", (ctx) => + response( + Effect.gen(function* () { + const fs = yield* FileSystem.Service + return yield* fs.list(ctx.query) + }), + ), + ) + .handle("fs.find", (ctx) => + response( + Effect.gen(function* () { + const fs = yield* FileSystem.Service + return yield* fs.find(ctx.query) + }), + ), + ) + }), +) diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts new file mode 100644 index 0000000000000000000000000000000000000000..60000b3fc009079682145f0d6b4181118ea72137 --- /dev/null +++ b/packages/server/src/handlers/health.ts @@ -0,0 +1,7 @@ +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" + +export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => + handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })), +) diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c29d58776078ad54c436cd91c73daba3feab495 --- /dev/null +++ b/packages/server/src/handlers/integration.ts @@ -0,0 +1,104 @@ +import { Integration } from "@opencode-ai/core/integration" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" +import { response } from "../location" + +const authorize = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + () => + new InvalidRequestError({ + message: "Authentication failed", + kind: "integration_authorization", + }), + ), + ) + +export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration", (handlers) => + Effect.gen(function* () { + return handlers + .handle( + "integration.list", + Effect.fn(function* () { + const service = yield* Integration.Service + return yield* response(service.list()) + }), + ) + .handle( + "integration.get", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + return yield* response(service.get(ctx.params.integrationID)) + }), + ) + .handle( + "integration.connect.key", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + yield* authorize( + service.connection.key({ + integrationID: ctx.params.integrationID, + key: ctx.payload.key, + label: ctx.payload.label, + }), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "integration.connect.oauth", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + return yield* response( + authorize( + service.connection.oauth({ + integrationID: ctx.params.integrationID, + methodID: ctx.payload.methodID, + inputs: ctx.payload.inputs, + label: ctx.payload.label, + }), + ), + ) + }), + ) + .handle( + "integration.attempt.status", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + return yield* response(service.attempt.status(ctx.params.attemptID)) + }), + ) + .handle( + "integration.attempt.complete", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + yield* service.attempt.complete({ attemptID: ctx.params.attemptID, code: ctx.payload.code }).pipe( + Effect.mapError( + (error) => + new InvalidRequestError({ + message: + error._tag === "Integration.CodeRequired" + ? "Authorization code is required" + : "Authentication failed", + kind: + error._tag === "Integration.CodeRequired" + ? "integration_code_required" + : "integration_authorization", + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "integration.attempt.cancel", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + yield* service.attempt.cancel(ctx.params.attemptID) + return HttpApiSchema.NoContent.make() + }), + ) + }), +) diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts new file mode 100644 index 0000000000000000000000000000000000000000..ded8c8c2e0ac5c329979b1fa6281cc33600981cb --- /dev/null +++ b/packages/server/src/handlers/location.ts @@ -0,0 +1,18 @@ +import { Location } from "@opencode-ai/core/location" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" + +export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) => + handlers.handle( + "location.get", + Effect.fn(function* () { + const location = yield* Location.Service + return new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }) + }), + ), +) diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts new file mode 100644 index 0000000000000000000000000000000000000000..93734c628d8e6295f607ad5055f0010f7d48ec79 --- /dev/null +++ b/packages/server/src/handlers/message.ts @@ -0,0 +1,81 @@ +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionV2 } from "@opencode-ai/core/session" +import { Effect, Schema } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors" + +const DefaultMessagesLimit = 50 + +const Cursor = Schema.Struct({ + id: SessionMessage.ID, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), +}) + +const decodeCursor = Schema.decodeUnknownSync(Cursor) + +const cursor = { + encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers.handle( + "session.messages", + Effect.fn(function* (ctx) { + if (ctx.query.cursor && ctx.query.order !== undefined) + return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" }) + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), + catch: () => new InvalidCursorError({ message: "Invalid cursor" }), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const messages = yield* session + .messages({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit ?? DefaultMessagesLimit, + order, + cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.MessageDecodeError", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to decode session message").pipe( + Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), + Effect.andThen( + Effect.fail( + new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }), + ), + ), + ) + }), + ) + const first = messages[0] + const last = messages.at(-1) + return { + data: messages, + cursor: { + previous: first ? cursor.encode(first, order, "previous") : undefined, + next: last ? cursor.encode(last, order, "next") : undefined, + }, + } + }), + ) + }), +) diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..36639ae7b1e64d537d4ac6ed2920fed81d3f8e1b --- /dev/null +++ b/packages/server/src/handlers/model.ts @@ -0,0 +1,17 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => + Effect.gen(function* () { + return handlers.handle( + "model.list", + Effect.fn(function* () { + const catalog = yield* Catalog.Service + return yield* response(catalog.model.available()) + }), + ) + }), +) diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts new file mode 100644 index 0000000000000000000000000000000000000000..0425c14996164eec9170d1db7a9492463155b49f --- /dev/null +++ b/packages/server/src/handlers/permission.ts @@ -0,0 +1,98 @@ +import { Location } from "@opencode-ai/core/location" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" + +function missingRequest(id: PermissionV2.ID) { + return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) +} + +export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) => + Effect.gen(function* () { + return handlers + .handle( + "permission.request.list", + Effect.fn(function* () { + return yield* response((yield* PermissionV2.Service).list()) + }), + ) + .handle( + "session.permission.create", + Effect.fn(function* (ctx) { + const permission = yield* PermissionV2.Service + return { + data: yield* permission + .ask({ + id: ctx.payload.id, + sessionID: ctx.params.sessionID, + action: ctx.payload.action, + resources: ctx.payload.resources, + save: ctx.payload.save, + metadata: ctx.payload.metadata, + source: ctx.payload.source, + agent: ctx.payload.agent, + }) + .pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + } + }), + ) + .handle( + "session.permission.list", + Effect.fn(function* (ctx) { + const permission = yield* PermissionV2.Service + return { data: yield* permission.forSession(ctx.params.sessionID) } + }), + ) + .handle( + "session.permission.get", + Effect.fn(function* (ctx) { + const request = yield* (yield* PermissionV2.Service).get(ctx.params.requestID) + if (!request || request.sessionID !== ctx.params.sessionID) return yield* missingRequest(ctx.params.requestID) + return { data: request } + }), + ) + .handle( + "session.permission.reply", + Effect.fn(function* (ctx) { + const permission = yield* PermissionV2.Service + const request = yield* permission.get(ctx.params.requestID) + if (!request || request.sessionID !== ctx.params.sessionID) return yield* missingRequest(ctx.params.requestID) + yield* permission + .reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message }) + .pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID))) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "permission.saved.list", + Effect.fn(function* (ctx) { + const location = yield* Location.Service + return { + data: yield* (yield* PermissionSaved.Service).list({ + projectID: ctx.query.projectID ?? location.project.id, + }), + } + }), + ) + .handle( + "permission.saved.remove", + Effect.fn(function* (ctx) { + yield* (yield* PermissionSaved.Service).remove(ctx.params.id) + return HttpApiSchema.NoContent.make() + }), + ) + }), +) diff --git a/packages/server/src/handlers/project-copy.ts b/packages/server/src/handlers/project-copy.ts new file mode 100644 index 0000000000000000000000000000000000000000..3733db6771a4df2d529722245b3b4b4b1cf724b6 --- /dev/null +++ b/packages/server/src/handlers/project-copy.ts @@ -0,0 +1,68 @@ +import { Location } from "@opencode-ai/core/location" +import { ProjectCopy } from "@opencode-ai/core/project/copy" +import { Git } from "@opencode-ai/core/git" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy" + +export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) => + Effect.succeed( + handlers + .handle("projectCopy.create", (ctx) => + Effect.gen(function* () { + const copies = yield* ProjectCopy.Service + const location = yield* Location.Service + return yield* badRequest( + copies.create({ + ...ctx.payload, + projectID: ctx.params.projectID, + sourceDirectory: location.project.directory, + }), + ) + }), + ) + .handle("projectCopy.remove", (ctx) => + ProjectCopy.Service.use((copies) => + badRequest(copies.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe( + Effect.as(HttpApiSchema.NoContent.make()), + ), + ), + ) + .handle("projectCopy.refresh", (ctx) => + ProjectCopy.Service.use((copies) => + badRequest(copies.refresh({ projectID: ctx.params.projectID })).pipe( + Effect.as(HttpApiSchema.NoContent.make()), + ), + ), + ), + ), +) + +function badRequest(effect: Effect.Effect) { + return effect.pipe( + Effect.mapError( + (error) => + new ProjectCopyError({ + name: "ProjectCopyError", + data: { + message: message(error), + forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined, + }, + }), + ), + ) +} + +function message(error: ProjectCopy.Error) { + if (error instanceof ProjectCopy.SourceDirectoryNotFoundError) + return `Project copy source not found: ${error.directory}` + if (error instanceof ProjectCopy.DestinationExistsError) + return `Project copy destination already exists: ${error.directory}` + if (error instanceof ProjectCopy.DirectoryUnavailableError) + return `Project copy directory unavailable: ${error.directory}` + if (error instanceof ProjectCopy.InvalidDirectoryError) return `Invalid project copy directory: ${error.directory}` + if (error instanceof ProjectCopy.StrategyUnavailableError) + return `Project copy strategy unavailable: ${error.strategy}` + return error.message +} diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3f25ab0dfc395295f46637debe31960bec2cd29 --- /dev/null +++ b/packages/server/src/handlers/provider.ts @@ -0,0 +1,32 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" + +export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => + Effect.gen(function* () { + return handlers + .handle( + "provider.list", + Effect.fn(function* () { + const catalog = yield* Catalog.Service + return yield* response(catalog.provider.available()) + }), + ) + .handle( + "provider.get", + Effect.fn(function* (ctx) { + const catalog = yield* Catalog.Service + const provider = yield* catalog.provider.get(ctx.params.providerID) + if (!provider) + return yield* new ProviderNotFoundError({ + providerID: ctx.params.providerID, + message: `Provider not found: ${ctx.params.providerID}`, + }) + return yield* response(Effect.succeed(provider)) + }), + ) + }), +) diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts new file mode 100644 index 0000000000000000000000000000000000000000..cda2cf43e9ead9363c9a6cdcb08394e81e1ffab7 --- /dev/null +++ b/packages/server/src/handlers/pty.ts @@ -0,0 +1,223 @@ +import { Pty } from "@opencode-ai/core/pty" +import { PtyProtocol } from "@opencode-ai/core/pty/protocol" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { Location } from "@opencode-ai/core/location" +import { Effect, Queue } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" +import { Api } from "../api" +import { CorsConfig, isAllowedRequestOrigin } from "../cors" +import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@opencode-ai/protocol/groups/pty" +import { response } from "../location" +import { PtyEnvironment } from "../pty-environment" + +const ticketScope = Effect.gen(function* () { + const location = yield* Location.Service + return { directory: location.directory as string, workspaceID: location.workspaceID } +}) + +export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig + const environment = yield* PtyEnvironment.Service + + return handlers + .handle( + "pty.list", + Effect.fn(function* () { + return yield* response((yield* Pty.Service).list()) + }), + ) + .handle( + "pty.create", + Effect.fn(function* (ctx) { + const pty = yield* Pty.Service + const location = yield* Location.Service + const cwd = ctx.payload.cwd || location.directory + return yield* response( + pty.create({ + ...ctx.payload, + args: ctx.payload.args ? [...ctx.payload.args] : undefined, + cwd, + env: { + ...ctx.payload.env, + ...(yield* environment.get({ directory: location.directory, cwd })), + }, + }), + ) + }), + ) + .handle( + "pty.get", + Effect.fn(function* (ctx) { + const pty = yield* Pty.Service + return yield* response( + pty.get(ctx.params.ptyID).pipe( + Effect.catchTag( + "Pty.NotFoundError", + () => + new PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), + ), + ), + ) + }), + ) + .handle( + "pty.update", + Effect.fn(function* (ctx) { + const pty = yield* Pty.Service + return yield* response( + pty + .update(ctx.params.ptyID, { + ...ctx.payload, + size: ctx.payload.size ? { ...ctx.payload.size } : undefined, + }) + .pipe( + Effect.catchTag( + "Pty.NotFoundError", + () => + new PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), + ), + ), + ) + }), + ) + .handle( + "pty.remove", + Effect.fn(function* (ctx) { + const pty = yield* Pty.Service + yield* pty.remove(ctx.params.ptyID).pipe( + Effect.catchTag( + "Pty.NotFoundError", + () => + new PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "pty.connectToken", + Effect.fn(function* (ctx) { + const request = yield* HttpServerRequest.HttpServerRequest + // The custom header forces a CORS preflight, so cross-origin browser pages cannot + // mint tickets without passing the server's origin policy. + if ( + request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || + !isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors) + ) + return yield* new ForbiddenError({ message: "Invalid PTY connect token request" }) + const pty = yield* Pty.Service + yield* pty.get(ctx.params.ptyID).pipe( + Effect.catchTag( + "Pty.NotFoundError", + () => + new PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), + ), + ) + return yield* response(tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) })) + }), + ) + .handleRaw( + "pty.connect", + Effect.fn("PtyHandler.connect")(function* (ctx) { + const pty = yield* Pty.Service + const exists = yield* pty.get(ctx.params.ptyID).pipe( + Effect.as(true), + Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)), + ) + if (!exists) return HttpServerResponse.empty({ status: 404 }) + + const url = new URL(ctx.request.url, "http://localhost") + const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + const valid = isAllowedRequestOrigin(ctx.request.headers.origin, ctx.request.headers.host, cors) + ? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* ticketScope) }) + : false + if (!valid) return HttpServerResponse.empty({ status: 403 }) + } + const parsedCursor = url.searchParams.get("cursor") + const cursorNumber = parsedCursor === null ? undefined : Number(parsedCursor) + const cursor = + cursorNumber !== undefined && Number.isSafeInteger(cursorNumber) && cursorNumber >= -1 + ? cursorNumber + : undefined + + const socket = yield* Effect.orDie(ctx.request.upgrade) + const write = yield* socket.writer + const closeAccepted = (event: Socket.CloseEvent) => + socket + .runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + + // Outbound frames flow through one queue drained by a single writer so replay, live + // output, and the close frame keep their order. + // TODO: Integrate graceful-shutdown socket tracking before clients migrate to this route. + const outbox = yield* Queue.unbounded() + const attachment = yield* pty + .attach(ctx.params.ptyID, { + cursor, + onData: (chunk) => Queue.offerUnsafe(outbox, chunk), + onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)), + }) + .pipe( + Effect.catchTags({ + "Pty.NotFoundError": () => + closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), + "Pty.ExitedError": () => + closeAccepted(new Socket.CloseEvent(4404, "session exited")).pipe(Effect.as(undefined)), + }), + ) + if (!attachment) return HttpServerResponse.empty() + + for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk) + Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor)) + attachment.activate() + + const drain = Effect.gen(function* () { + while (true) { + const item = yield* Queue.take(outbox) + yield* write(item) + if (item instanceof Socket.CloseEvent) return + } + }) + + yield* Effect.race( + drain, + socket.runRaw((message) => { + const decoded = PtyProtocol.decodeInput(message) + if (decoded !== undefined) attachment.write(decoded) + }), + ).pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring(Effect.sync(() => attachment.detach())), + Effect.orDie, + ) + return HttpServerResponse.empty() + }), + ) + }), +) diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts new file mode 100644 index 0000000000000000000000000000000000000000..954afe0df5876953cae1d928d2dd2753ee81e28f --- /dev/null +++ b/packages/server/src/handlers/question.ts @@ -0,0 +1,62 @@ +import { QuestionV2 } from "@opencode-ai/core/question" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { QuestionNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" + +function missingRequest(id: QuestionV2.ID) { + return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` }) +} + +export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) => + Effect.gen(function* () { + const withOwnedQuestion = Effect.fnUntraced(function* ( + sessionID: QuestionV2.Request["sessionID"], + requestID: QuestionV2.ID, + use: (question: QuestionV2.Interface) => Effect.Effect, + ) { + const question = yield* QuestionV2.Service + const request = (yield* question.list()).find((request) => request.id === requestID) + if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID) + return yield* use(question) + }) + + return handlers + .handle( + "question.request.list", + Effect.fn(function* () { + return yield* response((yield* QuestionV2.Service).list()) + }), + ) + .handle( + "session.question.list", + Effect.fn(function* (ctx) { + const requests = yield* (yield* QuestionV2.Service).list() + return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) } + }), + ) + .handle( + "session.question.reply", + Effect.fn(function* (ctx) { + yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) => + question + .reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers }) + .pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.question.reject", + Effect.fn(function* (ctx) { + yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) => + question + .reject(ctx.params.requestID) + .pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))), + ) + return HttpApiSchema.NoContent.make() + }), + ) + }), +) diff --git a/packages/server/src/handlers/reference.ts b/packages/server/src/handlers/reference.ts new file mode 100644 index 0000000000000000000000000000000000000000..543c9790dc878e11de4047ac56010715a328476a --- /dev/null +++ b/packages/server/src/handlers/reference.ts @@ -0,0 +1,8 @@ +import { Reference } from "@opencode-ai/core/reference" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) => + handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))), +) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b7d354b04fc32567e41582e6a8e74537be6e57d --- /dev/null +++ b/packages/server/src/handlers/session.ts @@ -0,0 +1,385 @@ +import { SessionV2 } from "@opencode-ai/core/session" +import { DateTime, Effect, Stream } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { SessionsCursor } from "@opencode-ai/protocol/groups/session" +import { + ConflictError, + InvalidCursorError, + MessageNotFoundError, + ServiceUnavailableError, + SessionNotFoundError, + UnknownError, +} from "@opencode-ai/protocol/errors" +import { AbsolutePath } from "@opencode-ai/core/schema" + +const DefaultSessionsLimit = 50 +const DefaultSessionHistoryLimit = 50 + +export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers + .handle( + "session.list", + Effect.fn(function* (ctx) { + const query = + ctx.query.cursor !== undefined + ? yield* SessionsCursor.parse(ctx.query.cursor).pipe( + Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), + ) + : ctx.query + const sessions = yield* session.list({ + ...query, + workspaceID: query.workspace, + limit: ctx.query.limit ?? DefaultSessionsLimit, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + data: sessions, + cursor: { + previous: first + ? SessionsCursor.make({ + ...query, + anchor: { + id: first.id, + time: DateTime.toEpochMillis(first.time.created), + direction: "previous", + }, + }) + : undefined, + next: last + ? SessionsCursor.make({ + ...query, + anchor: { + id: last.id, + time: DateTime.toEpochMillis(last.time.created), + direction: "next", + }, + }) + : undefined, + }, + } + }), + ) + .handle( + "session.create", + Effect.fn(function* (ctx) { + return { + data: yield* session.create({ + id: ctx.payload.id, + agent: ctx.payload.agent, + model: ctx.payload.model, + location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, + }), + } + }), + ) + .handle( + "session.active", + Effect.fn(function* () { + return { + data: Object.fromEntries( + Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]), + ), + } + }), + ) + .handle( + "session.get", + Effect.fn(function* (ctx) { + return { + data: yield* session.get(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + } + }), + ) + .handle( + "session.switchAgent", + Effect.fn(function* (ctx) { + yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.switchModel", + Effect.fn(function* (ctx) { + yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.prompt", + Effect.fn(function* (ctx) { + return { + data: yield* session + .prompt({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery, + resume: ctx.payload.resume, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.PromptConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, + resource: error.messageID, + }), + ), + ), + ), + } + }), + ) + .handle( + "session.compact", + Effect.fn(function* (ctx) { + yield* session.compact({ sessionID: ctx.params.sessionID }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.OperationUnavailableError", (error) => + Effect.fail( + new ServiceUnavailableError({ + message: `Session ${error.operation} is not available yet`, + service: `session.${error.operation}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.wait", + Effect.fn(function* (ctx) { + yield* session.wait(ctx.params.sessionID).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.OperationUnavailableError", (error) => + Effect.fail( + new ServiceUnavailableError({ + message: `Session ${error.operation} is not available yet`, + service: `session.${error.operation}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.revert.stage", + Effect.fn(function* (ctx) { + return { + data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + Effect.catchTag( + "Session.MessageNotFoundError", + (error) => + new MessageNotFoundError({ + sessionID: error.sessionID, + messageID: error.messageID, + message: `Message not found: ${error.messageID}`, + }), + ), + Effect.catchTag("Snapshot.Error", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to stage session revert", { cause: error }).pipe( + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), + ), + } + }), + ) + .handle( + "session.revert.clear", + Effect.fn(function* (ctx) { + yield* session.revert.clear(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + Effect.catchTag("Snapshot.Error", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to clear session revert", { cause: error }).pipe( + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.revert.commit", + Effect.fn(function* (ctx) { + yield* session.revert.commit(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.context", + Effect.fn(function* (ctx) { + return { + data: yield* session.context(ctx.params.sessionID).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.MessageDecodeError", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to decode session message").pipe( + Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), + Effect.andThen( + Effect.fail( + new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }), + ), + ), + ) + }), + ), + } + }), + ) + .handle( + "session.history", + Effect.fn(function* (ctx) { + return yield* session + .history({ + sessionID: ctx.params.sessionID, + after: ctx.query.after, + limit: ctx.query.limit ?? DefaultSessionHistoryLimit, + }) + .pipe( + Effect.map((page) => ({ + data: page.events, + hasMore: page.hasMore, + })), + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + }), + ) + .handle( + "session.events", + Effect.fn((ctx) => + Effect.succeed( + session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie), + ), + ), + ) + .handle( + "session.interrupt", + Effect.fn(function* (ctx) { + yield* session.interrupt(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.message", + Effect.fn(function* (ctx) { + const message = yield* session.message(ctx.params) + if (message) return { data: message } + return yield* new MessageNotFoundError({ + sessionID: ctx.params.sessionID, + messageID: ctx.params.messageID, + message: `Message not found: ${ctx.params.messageID}`, + }) + }), + ) + }), +) diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ffeaca8ea21a8c6a40ca7e39da2685e55854a11 --- /dev/null +++ b/packages/server/src/handlers/skill.ts @@ -0,0 +1,8 @@ +import { SkillV2 } from "@opencode-ai/core/skill" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) => + handlers.handle("skill.list", () => response(SkillV2.Service.use((skill) => skill.list()))), +) diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ae5aa6e6d08b26ed063ad7841b7195b3096ff7a --- /dev/null +++ b/packages/server/src/location.ts @@ -0,0 +1,60 @@ +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { Effect, Layer } from "effect" +import { HttpServerRequest } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +export type LocationServices = Layer.Success> + +export class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiLocation", +) {} + +export function response(data: Effect.Effect) { + return Effect.gen(function* () { + const location = yield* Location.Service + return { + location: new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }), + data: yield* data, + } + }) +} + +function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { + const query = new URL(request.url, "http://localhost").searchParams + const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"] + const directory = + query.get("location[directory]") || + (request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd()) + return Location.Ref.make({ + directory: AbsolutePath.make(directory), + workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined, + }) +} + +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export const layer = Layer.effect( + LocationMiddleware, + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + return LocationMiddleware.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* effect.pipe(Effect.provide(locations.get(ref(request)))) + }), + ) + }), +) diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc785ee2472a401228539b9d7cb3a8733fa1c735 --- /dev/null +++ b/packages/server/src/middleware/authorization.ts @@ -0,0 +1,58 @@ +import { ServerAuth } from "../auth" +import { UnauthorizedError } from "@opencode-ai/protocol/errors" +import { Authorization } from "@opencode-ai/protocol/middleware/authorization" +export { Authorization } from "@opencode-ai/protocol/middleware/authorization" +import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" +import { Effect, Encoding, Layer, Redacted } from "effect" +import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" + +const AUTH_TOKEN_QUERY = "auth_token" +const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' + +function emptyCredential() { + return { username: "", password: Redacted.make("") } +} + +function decodeCredential(input: string) { + return Effect.fromResult(Encoding.decodeBase64String(input)).pipe( + Effect.match({ + onFailure: emptyCredential, + onSuccess: (header) => { + const separator = header.indexOf(":") + if (separator === -1) return emptyCredential() + return { username: header.slice(0, separator), password: Redacted.make(header.slice(separator + 1)) } + }, + }), + ) +} + +function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { + const url = new URL(request.url, "http://localhost") + const token = url.searchParams.get(AUTH_TOKEN_QUERY) + if (token) return decodeCredential(token) + const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") + if (match) return decodeCredential(match[1]) + return Effect.succeed(emptyCredential()) +} + +export const authorizationLayer = Layer.effect( + Authorization, + Effect.gen(function* () { + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return Authorization.of((effect) => effect) + return Authorization.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + // Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips + // credential checks here; the connect handler consumes and validates the ticket. + if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect + const credential = yield* credentialFromRequest(request) + if (ServerAuth.authorized(credential, config)) return yield* effect + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)), + ) + return yield* new UnauthorizedError({ message: "Authentication required" }) + }), + ) + }), +) diff --git a/packages/server/src/middleware/schema-error.ts b/packages/server/src/middleware/schema-error.ts new file mode 100644 index 0000000000000000000000000000000000000000..37013285b9d7dff7916c99c06c86466c92cecf67 --- /dev/null +++ b/packages/server/src/middleware/schema-error.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" +import { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error" +export { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error" + +const REASON_LIMIT = 1024 + +function truncateReason(reason: string) { + if (reason.length <= REASON_LIMIT) return reason + return reason.slice(0, REASON_LIMIT) + `... (${reason.length - REASON_LIMIT} more chars)` +} + +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { + const reason = truncateReason(error.cause.message) + return Effect.logWarning("schema rejection").pipe( + Effect.annotateLogs({ kind: error.kind, reason }), + Effect.andThen(Effect.fail(new InvalidRequestError({ message: reason, kind: error.kind }))), + ) +}) diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts new file mode 100644 index 0000000000000000000000000000000000000000..86fa80f75c1ed4b6113a568718b4221c25717041 --- /dev/null +++ b/packages/server/src/middleware/session-location.ts @@ -0,0 +1,67 @@ +import { Database } from "@opencode-ai/core/database/database" +import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { eq } from "drizzle-orm" +import { Effect, Layer, Schema } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import type { LocationServices } from "../location" + +export class SessionLocationMiddleware extends HttpApiMiddleware.Service< + SessionLocationMiddleware, + { provides: LocationServices } +>()("@opencode/HttpApiSessionLocation", { + error: [InvalidRequestError, SessionNotFoundError], +}) {} + +const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID) + +export const sessionLocationLayer = Layer.effect( + SessionLocationMiddleware, + Effect.gen(function* () { + const { db } = yield* Database.Service + const locations = yield* LocationServiceMap.Service + + return SessionLocationMiddleware.of((effect) => + Effect.gen(function* () { + const route = yield* HttpRouter.RouteContext + const sessionID = yield* decodeSessionID(route.params.sessionID).pipe( + Effect.mapError( + () => + new InvalidRequestError({ + message: "Invalid session ID", + field: "sessionID", + }), + ), + ) + const row = yield* db + .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!row) + return yield* new SessionNotFoundError({ + sessionID, + message: `Session not found: ${sessionID}`, + }) + + return yield* effect.pipe( + Effect.provide( + locations.get( + Location.Ref.make({ + directory: AbsolutePath.make(row.directory), + workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined, + }), + ), + ), + ) + }), + ) + }), +) diff --git a/packages/server/src/pty-environment.ts b/packages/server/src/pty-environment.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e18a6f9258f5848ce011dc6f4d3fbca16c58932 --- /dev/null +++ b/packages/server/src/pty-environment.ts @@ -0,0 +1,19 @@ +export * as PtyEnvironment from "./pty-environment" + +import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" + +export interface Interface { + readonly get: (input: { directory: string; cwd: string }) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/ServerPtyEnvironment") {} + +export const layer = Layer.succeed( + Service, + Service.of({ + get: () => Effect.succeed({}), + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc1b1ae6a55d77a8898cbb4f7135527110172fae --- /dev/null +++ b/packages/server/src/routes.ts @@ -0,0 +1,68 @@ +import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { EventV2 } from "@opencode-ai/core/event" +import { Credential } from "@opencode-ai/core/credential" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Layer, Option } from "effect" +import { Api } from "./api" +import { ServerAuth } from "./auth" +import { handlers } from "./handlers" +import { authorizationLayer } from "./middleware/authorization" +import { schemaErrorLayer } from "./middleware/schema-error" +import { PtyEnvironment } from "./pty-environment" +import { layer as locationLayer } from "./location" +import { sessionLocationLayer } from "./middleware/session-location" + +const applicationServices = LayerNode.group([ + Database.node, + EventV2.node, + httpClient, + ToolOutputStore.cleanupNode, + SessionV2.node, + PermissionSaved.node, + PtyTicket.node, + Credential.node, + PtyEnvironment.node, + LocationServiceMap.node, +]) + +export function createRoutes(password?: string) { + return makeRoutes( + password + ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) + : ServerAuth.Config.layer, + ) +} + +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) +} + +function makeRoutes(auth: Layer.Layer) { + const serviceLayer = AppNodeBuilder.build(applicationServices, [[SessionExecution.node, SessionExecutionLocal.node]]) + + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( + Layer.provide(handlers), + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), + Layer.provide(authorizationLayer), + Layer.provide(schemaErrorLayer), + Layer.provide(auth), + Layer.provide(serviceLayer), + ) +} + +export const routes = createRoutes() + +export const webHandler = () => + HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true }) diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd5523a2a04ea6cf1333bfd9c82bdd51310224cf --- /dev/null +++ b/packages/slack/src/index.ts @@ -0,0 +1,145 @@ +import { App } from "@slack/bolt" +import { createOpencode, type ToolPart } from "@opencode-ai/sdk" + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, + socketMode: true, + appToken: process.env.SLACK_APP_TOKEN, +}) + +console.log("🔧 Bot configuration:") +console.log("- Bot token present:", !!process.env.SLACK_BOT_TOKEN) +console.log("- Signing secret present:", !!process.env.SLACK_SIGNING_SECRET) +console.log("- App token present:", !!process.env.SLACK_APP_TOKEN) + +console.log("🚀 Starting opencode server...") +const opencode = await createOpencode({ + port: 0, +}) +console.log("✅ Opencode server ready") + +const sessions = new Map() +void (async () => { + const events = await opencode.client.event.subscribe() + for await (const event of events.stream) { + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.type === "tool") { + // Find the session for this tool update + for (const [_sessionKey, session] of sessions.entries()) { + if (session.sessionId === part.sessionID) { + void handleToolUpdate(part, session.channel, session.thread) + break + } + } + } + } + } +})() + +async function handleToolUpdate(part: ToolPart, channel: string, thread: string) { + if (part.state.status !== "completed") return + const toolMessage = `*${part.tool}* - ${part.state.title}` + await app.client.chat + .postMessage({ + channel, + thread_ts: thread, + text: toolMessage, + }) + .catch(() => {}) +} + +app.use(async ({ next, context }) => { + console.log("📡 Raw Slack event:", JSON.stringify(context, null, 2)) + await next() +}) + +app.message(async ({ message, say }) => { + console.log("📨 Received message event:", JSON.stringify(message, null, 2)) + + if (message.subtype || !("text" in message) || !message.text) { + console.log("⏭️ Skipping message - no text or has subtype") + return + } + + console.log("✅ Processing message:", message.text) + + const channel = message.channel + const thread = (message as any).thread_ts || message.ts + const sessionKey = `${channel}-${thread}` + + let session = sessions.get(sessionKey) + + if (!session) { + console.log("🆕 Creating new opencode session...") + const { client, server } = opencode + + const createResult = await client.session.create({ + body: { title: `Slack thread ${thread}` }, + }) + + if (createResult.error) { + console.error("❌ Failed to create session:", createResult.error) + await say({ + text: "Sorry, I had trouble creating a session. Please try again.", + thread_ts: thread, + }) + return + } + + console.log("✅ Created opencode session:", createResult.data.id) + + session = { client, server, sessionId: createResult.data.id, channel, thread } + sessions.set(sessionKey, session) + + const shareResult = await client.session.share({ path: { id: createResult.data.id } }) + if (!shareResult.error && shareResult.data) { + const sessionUrl = shareResult.data.share?.url + console.log("🔗 Session shared:", sessionUrl) + await app.client.chat.postMessage({ channel, thread_ts: thread, text: sessionUrl }) + } + } + + console.log("📝 Sending to opencode:", message.text) + const result = await session.client.session.prompt({ + path: { id: session.sessionId }, + body: { parts: [{ type: "text", text: message.text }] }, + }) + + console.log("📤 Opencode response:", JSON.stringify(result, null, 2)) + + if (result.error) { + console.error("❌ Failed to send message:", result.error) + await say({ + text: "Sorry, I had trouble processing your message. Please try again.", + thread_ts: thread, + }) + return + } + + const response = result.data + + // Build response text + const responseText = + response.info?.content || + response.parts + ?.filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join("\n") || + "I received your message but didn't have a response." + + console.log("💬 Sending response:", responseText) + + // Send main response (tool updates will come via live events) + await say({ text: responseText, thread_ts: thread }) +}) + +app.command("/test", async ({ command, ack, say }) => { + await ack() + console.log("🧪 Test command received:", JSON.stringify(command, null, 2)) + await say("🤖 Bot is working! I can hear you loud and clear.") +}) + +await app.start() +console.log("⚡️ Slack bot is running!") diff --git a/packages/stats/core/migrations/20260522121617_common_dust/migration.sql b/packages/stats/core/migrations/20260522121617_common_dust/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..0194c4878d1490fb14356c1806cde81d218603fb --- /dev/null +++ b/packages/stats/core/migrations/20260522121617_common_dust/migration.sql @@ -0,0 +1,42 @@ +CREATE TABLE `stat` ( + `id` bigint AUTO_INCREMENT PRIMARY KEY, + `grain` varchar(16) NOT NULL, + `period_start` datetime NOT NULL, + `period_end` datetime NOT NULL, + `dataset` varchar(64) NOT NULL DEFAULT 'all', + `tier` varchar(64) NOT NULL DEFAULT 'all', + `client` varchar(64) NOT NULL DEFAULT 'all', + `source` varchar(64) NOT NULL DEFAULT 'all', + `provider` varchar(128) NOT NULL, + `model` varchar(256) NOT NULL, + `provider_model` varchar(256) NOT NULL DEFAULT '', + `sessions` bigint NOT NULL DEFAULT 0, + `requests` bigint NOT NULL DEFAULT 0, + `input_tokens` bigint NOT NULL DEFAULT 0, + `output_tokens` bigint NOT NULL DEFAULT 0, + `reasoning_tokens` bigint NOT NULL DEFAULT 0, + `cache_read_tokens` bigint NOT NULL DEFAULT 0, + `total_tokens` bigint NOT NULL DEFAULT 0, + `input_cost_microcents` bigint NOT NULL DEFAULT 0, + `output_cost_microcents` bigint NOT NULL DEFAULT 0, + `total_cost_microcents` bigint NOT NULL DEFAULT 0, + `avg_duration_ms` decimal(12,2), + `p50_duration_ms` int, + `p95_duration_ms` int, + `avg_ttfb_ms` decimal(12,2), + `p50_ttfb_ms` int, + `p95_ttfb_ms` int, + `avg_output_tps` decimal(12,4), + `success_count` bigint NOT NULL DEFAULT 0, + `error_count` bigint NOT NULL DEFAULT 0, + `sample_count` bigint NOT NULL DEFAULT 0, + `rank_by_tokens` int, + `rank_by_requests` int, + `rank_by_cost` int, + `created_at` datetime NOT NULL DEFAULT (now()), + `updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `uniq_model_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`,`model`) +); +--> statement-breakpoint +CREATE INDEX `idx_leaderboard_tokens` ON `stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint +CREATE INDEX `idx_model` ON `stat` (`model`,`grain`,`period_start`); \ No newline at end of file diff --git a/packages/stats/core/migrations/20260522121617_common_dust/snapshot.json b/packages/stats/core/migrations/20260522121617_common_dust/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..d1516534ada6345a1cbbce8b1d93ab995b4c6230 --- /dev/null +++ b/packages/stats/core/migrations/20260522121617_common_dust/snapshot.json @@ -0,0 +1,623 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "72655266-65da-408e-bfd8-9f3a4ad817a5", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "ddl": [ + { + "name": "stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "stat" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "stat" + } + ], + "renames": [] +} diff --git a/packages/stats/core/migrations/20260523110335_cool_vin_gonzales/migration.sql b/packages/stats/core/migrations/20260523110335_cool_vin_gonzales/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..4d5c0abce6c2dd9c5edba17d9b89e81dba5f17cf --- /dev/null +++ b/packages/stats/core/migrations/20260523110335_cool_vin_gonzales/migration.sql @@ -0,0 +1,94 @@ +CREATE TABLE `geo_stat` ( + `id` bigint AUTO_INCREMENT PRIMARY KEY, + `grain` varchar(16) NOT NULL, + `period_start` datetime NOT NULL, + `period_end` datetime NOT NULL, + `dataset` varchar(64) NOT NULL DEFAULT 'all', + `tier` varchar(64) NOT NULL DEFAULT 'all', + `client` varchar(64) NOT NULL DEFAULT 'all', + `source` varchar(64) NOT NULL DEFAULT 'all', + `country` char(2) NOT NULL, + `continent` varchar(8) NOT NULL DEFAULT '', + `sessions` bigint NOT NULL DEFAULT 0, + `requests` bigint NOT NULL DEFAULT 0, + `input_tokens` bigint NOT NULL DEFAULT 0, + `output_tokens` bigint NOT NULL DEFAULT 0, + `reasoning_tokens` bigint NOT NULL DEFAULT 0, + `cache_read_tokens` bigint NOT NULL DEFAULT 0, + `total_tokens` bigint NOT NULL DEFAULT 0, + `input_cost_microcents` bigint NOT NULL DEFAULT 0, + `output_cost_microcents` bigint NOT NULL DEFAULT 0, + `total_cost_microcents` bigint NOT NULL DEFAULT 0, + `avg_duration_ms` decimal(12,2), + `p50_duration_ms` int, + `p95_duration_ms` int, + `avg_ttfb_ms` decimal(12,2), + `p50_ttfb_ms` int, + `p95_ttfb_ms` int, + `avg_output_tps` decimal(12,4), + `success_count` bigint NOT NULL DEFAULT 0, + `error_count` bigint NOT NULL DEFAULT 0, + `sample_count` bigint NOT NULL DEFAULT 0, + `market_share_tokens` decimal(10,6), + `market_share_requests` decimal(10,6), + `market_share_sessions` decimal(10,6), + `rank_by_tokens` int, + `rank_by_requests` int, + `rank_by_sessions` int, + `rank_by_cost` int, + `created_at` datetime NOT NULL DEFAULT (now()), + `updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `uniq_country_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`country`) +); +--> statement-breakpoint +CREATE TABLE `provider_stat` ( + `id` bigint AUTO_INCREMENT PRIMARY KEY, + `grain` varchar(16) NOT NULL, + `period_start` datetime NOT NULL, + `period_end` datetime NOT NULL, + `dataset` varchar(64) NOT NULL DEFAULT 'all', + `tier` varchar(64) NOT NULL DEFAULT 'all', + `client` varchar(64) NOT NULL DEFAULT 'all', + `source` varchar(64) NOT NULL DEFAULT 'all', + `provider` varchar(128) NOT NULL, + `sessions` bigint NOT NULL DEFAULT 0, + `requests` bigint NOT NULL DEFAULT 0, + `input_tokens` bigint NOT NULL DEFAULT 0, + `output_tokens` bigint NOT NULL DEFAULT 0, + `reasoning_tokens` bigint NOT NULL DEFAULT 0, + `cache_read_tokens` bigint NOT NULL DEFAULT 0, + `total_tokens` bigint NOT NULL DEFAULT 0, + `input_cost_microcents` bigint NOT NULL DEFAULT 0, + `output_cost_microcents` bigint NOT NULL DEFAULT 0, + `total_cost_microcents` bigint NOT NULL DEFAULT 0, + `avg_duration_ms` decimal(12,2), + `p50_duration_ms` int, + `p95_duration_ms` int, + `avg_ttfb_ms` decimal(12,2), + `p50_ttfb_ms` int, + `p95_ttfb_ms` int, + `avg_output_tps` decimal(12,4), + `success_count` bigint NOT NULL DEFAULT 0, + `error_count` bigint NOT NULL DEFAULT 0, + `sample_count` bigint NOT NULL DEFAULT 0, + `market_share_tokens` decimal(10,6), + `market_share_requests` decimal(10,6), + `market_share_sessions` decimal(10,6), + `rank_by_tokens` int, + `rank_by_requests` int, + `rank_by_sessions` int, + `rank_by_cost` int, + `created_at` datetime NOT NULL DEFAULT (now()), + `updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `uniq_provider_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`) +); +--> statement-breakpoint +RENAME TABLE `stat` TO `model_stat`;--> statement-breakpoint +CREATE INDEX `idx_country_map_tokens` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint +CREATE INDEX `idx_country_rank` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`rank_by_tokens`);--> statement-breakpoint +CREATE INDEX `idx_country` ON `geo_stat` (`country`,`grain`,`period_start`);--> statement-breakpoint +CREATE INDEX `idx_continent` ON `geo_stat` (`continent`,`grain`,`period_start`);--> statement-breakpoint +CREATE INDEX `idx_provider_leaderboard_tokens` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint +CREATE INDEX `idx_provider_market_share` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`market_share_tokens`);--> statement-breakpoint +CREATE INDEX `idx_provider_rank` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`rank_by_tokens`);--> statement-breakpoint +CREATE INDEX `idx_provider` ON `provider_stat` (`provider`,`grain`,`period_start`); \ No newline at end of file diff --git a/packages/stats/core/migrations/20260523110335_cool_vin_gonzales/snapshot.json b/packages/stats/core/migrations/20260523110335_cool_vin_gonzales/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..4298716d022c3134bb3e36332969e88553f9d6fb --- /dev/null +++ b/packages/stats/core/migrations/20260523110335_cool_vin_gonzales/snapshot.json @@ -0,0 +1,2033 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "e246639a-0da0-4fbd-b7bb-f1781d407780", + "prevIds": ["72655266-65da-408e-bfd8-9f3a4ad817a5"], + "ddl": [ + { + "name": "geo_stat", + "entityType": "tables" + }, + { + "name": "model_stat", + "entityType": "tables" + }, + { + "name": "provider_stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "char(2)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "country", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(8)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "continent", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "geo_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "provider_stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_country_period", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_map_tokens", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_rank", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "continent", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_continent", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_provider_period", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_leaderboard_tokens", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "market_share_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_market_share", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_rank", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider", + "entityType": "indexes", + "table": "provider_stat" + } + ], + "renames": [] +} diff --git a/packages/stats/core/migrations/20260528012726_worthless_ultimo/migration.sql b/packages/stats/core/migrations/20260528012726_worthless_ultimo/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..250c8117e4f235b16f8ebf0daef601a404c34a3c --- /dev/null +++ b/packages/stats/core/migrations/20260528012726_worthless_ultimo/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE `geo_stat` ADD `provider` varchar(128) DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE `geo_stat` ADD `model` varchar(256) DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE `geo_stat` DROP INDEX `uniq_country_period`;--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_country_period` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`,`model`,`country`);--> statement-breakpoint +CREATE INDEX `idx_country_model` ON `geo_stat` (`model`,`country`,`grain`,`period_start`); diff --git a/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json b/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..d156d9ab7b9261160317dcc37563fcd032f4bc5d --- /dev/null +++ b/packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json @@ -0,0 +1,2097 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "c7296da3-649d-44f8-a6d5-7cbd755b2c67", + "prevIds": ["e246639a-0da0-4fbd-b7bb-f1781d407780"], + "ddl": [ + { + "name": "geo_stat", + "entityType": "tables" + }, + { + "name": "model_stat", + "entityType": "tables" + }, + { + "name": "provider_stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "char(2)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "country", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(8)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "continent", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "geo_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "provider_stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_country_period", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_map_tokens", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_rank", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "continent", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_continent", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_model", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_provider_period", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_leaderboard_tokens", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "market_share_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_market_share", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_rank", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider", + "entityType": "indexes", + "table": "provider_stat" + } + ], + "renames": [] +} diff --git a/packages/stats/core/migrations/20260528114447_misty_black_knight/migration.sql b/packages/stats/core/migrations/20260528114447_misty_black_knight/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..4beb87b63bceb983973e38f402d6805333ce647b --- /dev/null +++ b/packages/stats/core/migrations/20260528114447_misty_black_knight/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `geo_stat` ADD `period_key` varchar(32) NOT NULL;--> statement-breakpoint +ALTER TABLE `model_stat` ADD `period_key` varchar(32) NOT NULL;--> statement-breakpoint +ALTER TABLE `provider_stat` ADD `period_key` varchar(32) NOT NULL; \ No newline at end of file diff --git a/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json b/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..d8508c18bd5af19b641577489d2f6564de7f4eb0 --- /dev/null +++ b/packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json @@ -0,0 +1,2139 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "cefe97ad-34ad-42f3-9343-47b1b7f478fa", + "prevIds": ["c7296da3-649d-44f8-a6d5-7cbd755b2c67"], + "ddl": [ + { + "name": "geo_stat", + "entityType": "tables" + }, + { + "name": "model_stat", + "entityType": "tables" + }, + { + "name": "provider_stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "char(2)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "country", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(8)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "continent", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "geo_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "provider_stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_country_period", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_map_tokens", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_rank", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "continent", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_continent", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_model", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_provider_period", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_leaderboard_tokens", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "market_share_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_market_share", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_rank", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider", + "entityType": "indexes", + "table": "provider_stat" + } + ], + "renames": [] +} diff --git a/packages/stats/core/migrations/20260528121120_easy_multiple_man/migration.sql b/packages/stats/core/migrations/20260528121120_easy_multiple_man/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..9eb65448695d22bc1d32466bb005eba85cf7b09a --- /dev/null +++ b/packages/stats/core/migrations/20260528121120_easy_multiple_man/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE `geo_stat` DROP COLUMN `period_start`;--> statement-breakpoint +ALTER TABLE `geo_stat` DROP COLUMN `period_end`;--> statement-breakpoint +ALTER TABLE `model_stat` DROP COLUMN `period_start`;--> statement-breakpoint +ALTER TABLE `model_stat` DROP COLUMN `period_end`;--> statement-breakpoint +ALTER TABLE `provider_stat` DROP COLUMN `period_start`;--> statement-breakpoint +ALTER TABLE `provider_stat` DROP COLUMN `period_end`; \ No newline at end of file diff --git a/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json b/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..193134b0af0c0baa416702bdd0125342528c3711 --- /dev/null +++ b/packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json @@ -0,0 +1,2055 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "9d4d1a06-7d28-4cb4-b0cd-3dfb7b481b8b", + "prevIds": ["cefe97ad-34ad-42f3-9343-47b1b7f478fa"], + "ddl": [ + { + "name": "geo_stat", + "entityType": "tables" + }, + { + "name": "model_stat", + "entityType": "tables" + }, + { + "name": "provider_stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "char(2)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "country", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(8)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "continent", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "geo_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "provider_stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_country_period", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_map_tokens", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_rank", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "continent", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_continent", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_model", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_provider_period", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_leaderboard_tokens", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "market_share_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_market_share", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_rank", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider", + "entityType": "indexes", + "table": "provider_stat" + } + ], + "renames": [] +} diff --git a/packages/stats/core/migrations/20260620000000_unique_users/migration.sql b/packages/stats/core/migrations/20260620000000_unique_users/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..3b9784b0dc453054b105a5932e9a5a2d1348cc7d --- /dev/null +++ b/packages/stats/core/migrations/20260620000000_unique_users/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `geo_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;--> statement-breakpoint +ALTER TABLE `model_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;--> statement-breakpoint +ALTER TABLE `provider_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0; diff --git a/packages/stats/core/migrations/20260826000000_model_retention/migration.sql b/packages/stats/core/migrations/20260826000000_model_retention/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..e69d7f5bf441f18784098e49498695b3732c84e9 --- /dev/null +++ b/packages/stats/core/migrations/20260826000000_model_retention/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE `model_retention` ( + `id` bigint AUTO_INCREMENT NOT NULL, + `cohort_date` char(10) NOT NULL, + `dataset` varchar(64) NOT NULL DEFAULT 'all', + `tier` varchar(64) NOT NULL DEFAULT 'all', + `provider` varchar(128) NOT NULL, + `model` varchar(256) NOT NULL, + `eligible_users` bigint NOT NULL DEFAULT 0, + `retained_users` bigint NOT NULL DEFAULT 0, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `model_retention_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_model_retention_cohort` UNIQUE(`cohort_date`,`dataset`,`tier`,`provider`,`model`) +); +--> statement-breakpoint +CREATE INDEX `idx_model_retention_recent` ON `model_retention` (`dataset`,`tier`,`cohort_date`); +--> statement-breakpoint +CREATE INDEX `idx_model_retention_model` ON `model_retention` (`model`,`cohort_date`); diff --git a/packages/stats/core/migrations/20260903161929_parched_patriot/migration.sql b/packages/stats/core/migrations/20260903161929_parched_patriot/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..e80bd5d3cdf2e760a0165c5e796845c3657d52da --- /dev/null +++ b/packages/stats/core/migrations/20260903161929_parched_patriot/migration.sql @@ -0,0 +1 @@ +CREATE INDEX `idx_country_model_range` ON `geo_stat` (`model`,`provider`,`grain`,`dataset`,`client`,`source`,`tier`,`period_key`); diff --git a/packages/stats/core/migrations/20260903161929_parched_patriot/snapshot.json b/packages/stats/core/migrations/20260903161929_parched_patriot/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..1c2302f5546b3a04f994802d7588b0d0904b1862 --- /dev/null +++ b/packages/stats/core/migrations/20260903161929_parched_patriot/snapshot.json @@ -0,0 +1,2367 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "43e72697-1bf9-4df7-bc8e-5ca091bf2ff1", + "prevIds": ["9d4d1a06-7d28-4cb4-b0cd-3dfb7b481b8b"], + "ddl": [ + { + "name": "geo_stat", + "entityType": "tables" + }, + { + "name": "model_retention", + "entityType": "tables" + }, + { + "name": "model_stat", + "entityType": "tables" + }, + { + "name": "provider_stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "char(2)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "country", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "varchar(8)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "continent", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unique_users", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "geo_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "char(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cohort_date", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "eligible_users", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "retained_users", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_retention" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unique_users", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "model_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(32)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_key", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unique_users", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "decimal(10,6)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "market_share_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_sessions", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_stat" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "geo_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_retention", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_stat", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "provider_stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_country_period", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_map_tokens", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_rank", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "continent", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_continent", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "country", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_model", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_country_model_range", + "entityType": "indexes", + "table": "geo_stat" + }, + { + "columns": [ + { + "value": "cohort_date", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_retention_cohort", + "entityType": "indexes", + "table": "model_retention" + }, + { + "columns": [ + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "cohort_date", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model_retention_recent", + "entityType": "indexes", + "table": "model_retention" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "cohort_date", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model_retention_model", + "entityType": "indexes", + "table": "model_retention" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "model_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_provider_period", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_leaderboard_tokens", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "market_share_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_market_share", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "rank_by_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider_rank", + "entityType": "indexes", + "table": "provider_stat" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_key", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_provider", + "entityType": "indexes", + "table": "provider_stat" + } + ], + "renames": [] +} diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts new file mode 100644 index 0000000000000000000000000000000000000000..a61e3411456138f12f9b4d93c5b9021258911c3e --- /dev/null +++ b/packages/stats/core/src/domain/geo.ts @@ -0,0 +1,254 @@ +import { and, asc, eq, inArray, or } from "drizzle-orm" +import { Effect, Layer } from "effect" +import * as Context from "effect/Context" +import { DatabaseError, DrizzleClient } from "../database" +import { geoStat } from "../database/schema" +import { RETIRED_STAT_MODELS, RETIRED_STAT_PROVIDERS } from "./model-normalization" +import { + chunks, + collapseRows, + DATA_SITE_TIERS, + inserted, + isMissingUniqueUsersColumn, + omitUniqueUsers, + rankRowsWithMarketShare, + statPeriodKey, + statRowScope, + synthesizeAllTierRows, + toStatBaseRow, + UPSERT_CHUNK_SIZE, + type StatBaseAggregate, +} from "./stat" + +export type GeoStatRow = typeof geoStat.$inferInsert +export type GeoStatAggregate = StatBaseAggregate & { + provider: string + model: string + country: string + continent: string +} +export type GeoStatMetric = { + periodKey: string + updatedAt: Date + tier: string + provider: string + model: string + country: string + continent: string + totalTokens: number +} + +export declare namespace GeoStatRepo { + export interface Service { + readonly listDaily: (opts?: { + readonly provider?: string + readonly model?: string + }) => Effect.Effect + readonly listByPeriod: (opts: { + readonly grain: string + readonly periodKey: string + readonly dataset?: string + readonly tier?: string + readonly client?: string + readonly source?: string + readonly provider?: string + readonly model?: string + }) => Effect.Effect + readonly upsert: (rows: GeoStatRow[]) => Effect.Effect + readonly deleteRetiredDimensions: (rows: GeoStatRow[]) => Effect.Effect + } +} + +export class GeoStatRepo extends Context.Service()("@opencode/stats/GeoStatRepo") { + static readonly layer: Layer.Layer = Layer.effect( + GeoStatRepo, + Effect.gen(function* () { + const db = yield* DrizzleClient + + const listDaily = Effect.fn("GeoStatRepo.listDaily")(function* (opts?: { + readonly provider?: string + readonly model?: string + }) { + const scope = + opts?.model && opts.provider + ? and(eq(geoStat.provider, opts.provider), eq(geoStat.model, opts.model)) + : opts?.model + ? eq(geoStat.model, opts.model) + : and(eq(geoStat.provider, "all"), eq(geoStat.model, "all")) + return yield* Effect.tryPromise({ + try: () => + db + .select({ + periodKey: geoStat.period_key, + updatedAt: geoStat.updated_at, + tier: geoStat.tier, + provider: geoStat.provider, + model: geoStat.model, + country: geoStat.country, + continent: geoStat.continent, + totalTokens: geoStat.total_tokens, + }) + .from(geoStat) + .where( + and( + eq(geoStat.grain, "day"), + eq(geoStat.client, "all"), + eq(geoStat.source, "all"), + inArray(geoStat.tier, DATA_SITE_TIERS), + scope, + ), + ) + .orderBy(asc(geoStat.period_key)), + catch: (cause) => DatabaseError.make({ cause }), + }) + }) + + const listByPeriod = Effect.fn("GeoStatRepo.listByPeriod")(function* (opts: { + readonly grain: string + readonly periodKey: string + readonly dataset?: string + readonly tier?: string + readonly client?: string + readonly source?: string + readonly provider?: string + readonly model?: string + }) { + return yield* Effect.tryPromise({ + try: () => + db + .select() + .from(geoStat) + .where( + and( + eq(geoStat.grain, opts.grain), + eq(geoStat.period_key, opts.periodKey), + eq(geoStat.dataset, opts.dataset ?? "zen"), + eq(geoStat.tier, opts.tier ?? "all"), + eq(geoStat.client, opts.client ?? "all"), + eq(geoStat.source, opts.source ?? "all"), + eq(geoStat.provider, opts.provider ?? "all"), + eq(geoStat.model, opts.model ?? "all"), + ), + ), + catch: (cause) => DatabaseError.make({ cause }), + }) + }) + + const upsert = Effect.fn("GeoStatRepo.upsert")(function* (rows: GeoStatRow[]) { + yield* Effect.forEach( + chunks(rows, UPSERT_CHUNK_SIZE), + (chunk) => + Effect.tryPromise({ + try: async () => { + try { + return await upsertGeoChunk(chunk, true) + } catch (cause) { + if (!isMissingUniqueUsersColumn(cause)) throw cause + return upsertGeoChunk(chunk, false) + } + }, + catch: (cause) => DatabaseError.make({ cause }), + }), + { discard: true }, + ) + }) + + function upsertGeoChunk(chunk: GeoStatRow[], includeUniqueUsers: boolean) { + return db + .insert(geoStat) + .values(includeUniqueUsers ? chunk : omitUniqueUsers(chunk)) + .onDuplicateKeyUpdate({ + set: { + continent: inserted("continent"), + sessions: inserted("sessions"), + requests: inserted("requests"), + ...(includeUniqueUsers ? { unique_users: inserted("unique_users") } : {}), + input_tokens: inserted("input_tokens"), + output_tokens: inserted("output_tokens"), + reasoning_tokens: inserted("reasoning_tokens"), + cache_read_tokens: inserted("cache_read_tokens"), + total_tokens: inserted("total_tokens"), + input_cost_microcents: inserted("input_cost_microcents"), + output_cost_microcents: inserted("output_cost_microcents"), + total_cost_microcents: inserted("total_cost_microcents"), + avg_duration_ms: inserted("avg_duration_ms"), + p50_duration_ms: inserted("p50_duration_ms"), + p95_duration_ms: inserted("p95_duration_ms"), + avg_ttfb_ms: inserted("avg_ttfb_ms"), + p50_ttfb_ms: inserted("p50_ttfb_ms"), + p95_ttfb_ms: inserted("p95_ttfb_ms"), + avg_output_tps: inserted("avg_output_tps"), + success_count: inserted("success_count"), + error_count: inserted("error_count"), + sample_count: inserted("sample_count"), + market_share_tokens: inserted("market_share_tokens"), + market_share_requests: inserted("market_share_requests"), + market_share_sessions: inserted("market_share_sessions"), + rank_by_tokens: inserted("rank_by_tokens"), + rank_by_requests: inserted("rank_by_requests"), + rank_by_sessions: inserted("rank_by_sessions"), + rank_by_cost: inserted("rank_by_cost"), + }, + }) + } + + const deleteRetiredDimensions = Effect.fn("GeoStatRepo.deleteRetiredDimensions")(function* (rows: GeoStatRow[]) { + const scope = statRowScope(rows) + if (!scope) return + + yield* Effect.tryPromise({ + try: () => + db + .delete(geoStat) + .where( + and( + inArray(geoStat.grain, scope.grains), + inArray(geoStat.period_key, scope.periodKeys), + inArray(geoStat.dataset, scope.datasets), + inArray(geoStat.client, scope.clients), + inArray(geoStat.source, scope.sources), + or(inArray(geoStat.provider, RETIRED_STAT_PROVIDERS), inArray(geoStat.model, RETIRED_STAT_MODELS)), + ), + ), + catch: (cause) => DatabaseError.make({ cause }), + }) + }) + + return GeoStatRepo.of({ listDaily, listByPeriod, upsert, deleteRetiredDimensions }) + }), + ) +} + +export function rowsFromAggregates(aggregates: GeoStatAggregate[]) { + return rankRowsWithMarketShare( + [ + ...synthesizeAllTierRows( + collapseRows(aggregates.filter((item) => item.grain === "week").map(toRow), dimensionKey), + dimensionKey, + ), + ...synthesizeAllTierRows( + collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey), + dimensionKey, + ), + ], + marketShareKey, + ) +} + +function toRow(data: GeoStatAggregate): GeoStatRow { + return { + ...toStatBaseRow(data), + provider: data.provider, + model: data.model, + country: data.country, + continent: data.continent, + } +} + +function dimensionKey(row: GeoStatRow) { + return [row.provider, row.model, row.country].join("\u0000") +} + +function marketShareKey(row: GeoStatRow) { + return [statPeriodKey(row), row.provider, row.model].join("\u0000") +} diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts new file mode 100644 index 0000000000000000000000000000000000000000..6df9315ac798972a76e9aa17d2f7d3550bda0911 --- /dev/null +++ b/packages/stats/core/src/domain/home.ts @@ -0,0 +1,1133 @@ +import { Client } from "@planetscale/database" +import { Effect } from "effect" +import { Resource } from "sst/resource" +import type { ModelStatMetric } from "./model" +import { statProvider } from "./model-normalization" +import { isMissingRetentionTable } from "./retention" +import { DATA_SITE_TIERS, normalizeTier } from "./stat" + +export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise" +export type TokenProduct = "Zen" | "Go" | "Enterprise" +export type UsageRange = "1D" | "1W" | "2W" | "1M" | "2M" | "3M" | "YTD" | "ALL" +export type UsagePoint = { date: string; segments: { model: string; value: number }[] } +export type MarketDay = { date: string; total: number; authors: { author: string; share: number; tokens: number }[] } +export type LeaderboardEntry = { + model: string + provider: string + author: string + tokens: number + change: number | null + rank: number +} +export type TokenCostEntry = { model: string; total: number; input: number; output: number; cached: number } +export type CacheRatioEntry = { model: string; ratio: number; cached: number; uncached: number; total: number } +export type SessionCostEntry = { model: string; cost: number; tokens: number } +export type RetentionEntry = { + model: string + provider: string + author: string + rate: number + eligibleUserWeeks: number + retainedUserWeeks: number + rank: number | null +} +export type CountryEntry = { country: string; continent: string; tokens: number; share: number; rank: number } +export type ModelUsagePoint = { date: string; tokens: number; users: number; sessions: number; cost: number } +export type ModelMixEntry = { label: string; tokens: number; share: number } +export type ModelPeerEntry = { + model: string + provider: string + author: string + rank: number + tokens: number + share: number + slug: string +} +export type LabUsageModelEntry = { + model: string + provider: string + author: string + tokens: number + share: number + slug: string +} +export type StatsModelData = { + updatedAt: string | null + model: string + slug: string + provider: string + author: string + rank: number | null + previousRank: number | null + totalModels: number + tokenShare: number + tokenChange: number + weeklyRetention: RetentionEntry | null + totals: { + sessions: number + uniqueUsers: number + tokens: number + cost: number + tokensPerSession: number + costPerSession: number + costPerMillion: number + cacheRatio: number + } + usage: ModelUsagePoint[] + tokenMix: ModelMixEntry[] + country: CountryEntry[] + peers: ModelPeerEntry[] +} +export type StatsLabData = { + updatedAt: string | null + provider: string + author: string + tokenShare: number + tokenChange: number + totals: { + sessions: number + tokens: number + models: number + } + usage: ModelUsagePoint[] + models: LabUsageModelEntry[] +} +export type StatsModelComparisonEntry = { + updatedAt: string | null + model: string + slug: string + provider: string + author: string + rank: number | null + previousRank: number | null + totalModels: number + tokenShare: number + tokenChange: number + weeklyRetention: RetentionEntry | null + totals: StatsModelData["totals"] + usage: ModelUsagePoint[] +} +export type StatsModelComparisonInput = { + provider: string + model: string +} +export type StatsModelComparisonData = { + updatedAt: string | null + models: (StatsModelComparisonEntry | null)[] +} +export type StatsHomeData = { + updatedAt: string | null + usage: Record> + users: Record> + leaderboard: Record> + market: Record + tokenCost: Record + cacheRatio: Record + sessionCost: Record + retention: RetentionEntry[] + country: CountryEntry[] +} + +export class StatsDataError extends Error { + override name = "StatsDataError" + + constructor(readonly cause: unknown) { + super("Failed to load stats data") + } +} + +const DAY_MS = 86_400_000 +const TOKEN_SCALE = 1_000_000 +const DOLLARS_PER_MICROCENT = 1 / 100_000_000 +const METRIC_MODEL_LIMIT = 10 +const RETENTION_MODEL_LIMIT = 15 +const RETENTION_MIN_ELIGIBLE_USER_WEEKS = 100 +const RETENTION_COHORT_WEEKS = 7 +const TOP_MODEL_SEGMENT_LIMIT = 9 +const QUERY_CACHE_TTL_MS = 5 * 60 * 1000 +const QUERY_CACHE_MAX_ENTRIES = 256 +// Preserve the response shape while the public site presents Go and Free as one cohort. +const SITE_PRODUCT = "Go" +const SITE_TIER_PLACEHOLDERS = DATA_SITE_TIERS.map(() => "?").join(", ") +const LEADERBOARD_CHANGE_MIN_MULTIPLE = 10 +const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const + +type StatMetricRow = Omit & { + periodStart: number + updatedAt: number +} +type CountryTotalRow = { + country: string + continent: string + tokens: number + updatedAt: number +} +export type RetentionMetricRow = { + cohortDate: string + updatedAt: number + provider: string + model: string + eligibleUsers: number + retainedUsers: number +} + +type DateWindow = { start: number; end: number; previousStart: number; previousEnd: number } +type Bucket = { start: number; end: number; label: string } +type ModelAggregate = { + model: string + provider: string + sessions: number + uniqueUsers: number + inputTokens: number + outputTokens: number + reasoningTokens: number + cacheReadTokens: number + totalTokens: number + inputCostMicrocents: number + outputCostMicrocents: number + totalCostMicrocents: number +} + +type RawRow = Record +type CachedQuery = { expiresAt: number; value: Promise } + +const queryCache = new Map() + +export function getStatsHomeData(): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const [modelRows, retentionRows] = await Promise.all([listModelDaily(), listRetentionWeekly()]) + const window = modelRowsWindow(modelRows, "2M") + const geoRows = window ? await listCountryTotals(window) : [] + return buildStatsHomeData(modelRows, geoRows, retentionRows) + }, + catch: (cause) => new StatsDataError(cause), + }) +} + +export function getStatsModelData( + model: string, + provider?: string, +): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const [modelRows, retentionRows] = await Promise.all([listModelDaily(), listRetentionWeekly()]) + const normalized = modelRows.flatMap(normalizeStatRow) + const resolvedModel = resolveModelName(model, normalized, provider) + if (!resolvedModel) return null + const window = modelRowsWindow(modelRows, "2M") + const resolvedProvider = resolveModelProvider(resolvedModel, normalized, provider) + return buildStatsModelData( + resolvedModel, + modelRows, + window ? await listCountryTotals(window, { model: resolvedModel, provider: resolvedProvider }) : [], + provider, + retentionRows, + ) + }, + catch: (cause) => new StatsDataError(cause), + }) +} + +export function getStatsLabData(provider: string): Effect.Effect { + return Effect.tryPromise({ + try: async () => buildStatsLabData(provider, await listModelDaily()), + catch: (cause) => new StatsDataError(cause), + }) +} + +async function listModelDaily(): Promise { + return ( + await queryRows( + `select period_key, updated_at, tier, provider, model, sessions, unique_users, input_tokens, + output_tokens, reasoning_tokens, cache_read_tokens, total_tokens, input_cost_microcents, output_cost_microcents, + total_cost_microcents from model_stat where grain = 'day' and dataset = 'zen' and client = 'all' + and source = 'all' and tier in (${SITE_TIER_PLACEHOLDERS}) order by period_key`, + DATA_SITE_TIERS, + ) + ).map((row) => ({ + periodKey: stringValue(row.period_key), + updatedAt: dateValue(row.updated_at), + tier: stringValue(row.tier), + provider: stringValue(row.provider), + model: stringValue(row.model), + sessions: numberValue(row.sessions), + uniqueUsers: numberValue(row.unique_users), + inputTokens: numberValue(row.input_tokens), + outputTokens: numberValue(row.output_tokens), + reasoningTokens: numberValue(row.reasoning_tokens), + cacheReadTokens: numberValue(row.cache_read_tokens), + totalTokens: numberValue(row.total_tokens), + inputCostMicrocents: numberValue(row.input_cost_microcents), + outputCostMicrocents: numberValue(row.output_cost_microcents), + totalCostMicrocents: numberValue(row.total_cost_microcents), + })) +} + +async function listCountryTotals( + window: DateWindow, + opts?: { provider?: string; model?: string }, +): Promise { + const scope = + opts?.model && opts.provider + ? "and provider = ? and model = ?" + : opts?.model + ? "and model = ?" + : "and provider = 'all' and model = 'all'" + const params = opts?.model && opts.provider ? [opts.provider, opts.model] : opts?.model ? [opts.model] : [] + return ( + await queryRows( + `select country, max(continent) as continent, sum(total_tokens) as total_tokens, max(updated_at) as updated_at + from geo_stat where grain = 'day' and dataset = 'zen' and client = 'all' and source = 'all' + and tier in (${SITE_TIER_PLACEHOLDERS}) ${scope} and period_key >= ? and period_key < ? group by country`, + [...DATA_SITE_TIERS, ...params, periodKey(window.start), periodKey(window.end)], + ) + ).map((row) => ({ + updatedAt: dateValue(row.updated_at).getTime(), + country: stringValue(row.country) || "ZZ", + continent: stringValue(row.continent), + tokens: numberValue(row.total_tokens), + })) +} + +async function listRetentionWeekly(): Promise { + try { + return ( + await queryRows( + `select cohort_date, updated_at, provider, model, eligible_users, retained_users + from model_retention where dataset = 'zen' and tier = 'Go' order by cohort_date`, + ) + ).map((row) => ({ + cohortDate: stringValue(row.cohort_date), + updatedAt: dateValue(row.updated_at).getTime(), + provider: stringValue(row.provider), + model: stringValue(row.model), + eligibleUsers: numberValue(row.eligible_users), + retainedUsers: numberValue(row.retained_users), + })) + } catch (cause) { + if (isMissingRetentionTable(cause)) return [] + throw cause + } +} + +async function queryRows(query: string, params: string[] = []) { + const key = JSON.stringify([query, params]) + const now = Date.now() + const cached = queryCache.get(key) + if (cached && cached.expiresAt > now) return cached.value + if (cached) queryCache.delete(key) + + const value = new Client({ url: databaseUrl() }).execute(query, params).then((result) => result.rows as RawRow[]) + const entry = { expiresAt: now + QUERY_CACHE_TTL_MS, value } + queryCache.set(key, entry) + if (queryCache.size > QUERY_CACHE_MAX_ENTRIES) queryCache.delete(queryCache.keys().next().value!) + + return value.catch((cause) => { + if (queryCache.get(key) === entry) queryCache.delete(key) + throw cause + }) +} + +function databaseUrl() { + return process.env.DATABASE_URL ?? Resource.StatsDatabase.url +} + +function stringValue(value: unknown) { + return value == null ? "" : String(value) +} + +function numberValue(value: unknown) { + return Number(value ?? 0) +} + +function dateValue(value: unknown) { + return value instanceof Date ? value : new Date(stringValue(value)) +} + +export function getStatsModelsComparisonData( + models: readonly StatsModelComparisonInput[], +): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const [rows, retentionRows] = await Promise.all([listModelDaily(), listRetentionWeekly()]) + const entries = models.map((model) => + toComparisonEntry(buildStatsModelData(model.model, rows, [], model.provider, retentionRows)), + ) + const latest = entries + .map((model) => model?.updatedAt) + .flatMap((value) => (value ? [dateTime(value)] : [])) + .toSorted((a, b) => b - a)[0] + return { + updatedAt: latest === undefined ? null : new Date(latest).toISOString(), + models: entries, + } + }, + catch: (cause) => new StatsDataError(cause), + }) +} + +export const getStatsModelComparisonData = ( + firstProvider: string, + firstModel: string, + secondProvider: string, + secondModel: string, +) => + getStatsModelsComparisonData([ + { provider: firstProvider, model: firstModel }, + { provider: secondProvider, model: secondModel }, + ]) + +function buildStatsHomeData( + modelRows: ModelStatMetric[], + countryRows: CountryTotalRow[], + retentionRows: RetentionMetricRow[], +): StatsHomeData { + const normalized = modelRows.flatMap(normalizeStatRow) + if (normalized.length === 0) return emptyStatsHomeData() + + const earliest = Math.min(...normalized.map((row) => row.periodStart)) + const latest = Math.max(...normalized.map((row) => row.periodStart)) + const latestUpdate = Math.max(...normalized.map((row) => row.updatedAt), ...countryRows.map((row) => row.updatedAt)) + + return { + updatedAt: new Date(latestUpdate).toISOString(), + usage: createUsageProductRecord((product) => + createRangeRecord((range) => + buildUsagePoints( + normalized, + product, + range, + getWindow(range, earliest, latest), + getWindow("1W", earliest, latest), + ), + ), + ), + users: createUsageProductRecord((product) => + createRangeRecord((range) => + buildUsagePoints( + normalized, + product, + range, + getWindow(range, earliest, latest), + getWindow("1W", earliest, latest), + "users", + ), + ), + ), + leaderboard: createUsageProductRecord((product) => + createRangeRecord((_range) => buildLeaderboard(normalized, product, getWindow("1W", earliest, latest))), + ), + market: createRangeRecord((range) => buildMarketShare(normalized, "Go", range, getWindow(range, earliest, latest))), + tokenCost: createTokenProductRecord((product) => + buildTokenCost(normalized, product, getWindow("1W", earliest, latest)), + ), + cacheRatio: createTokenProductRecord((product) => + buildCacheRatio(normalized, product, getWindow("1W", earliest, latest)), + ), + sessionCost: createTokenProductRecord((product) => + buildSessionCost(normalized, product, getWindow("1W", earliest, latest)), + ), + retention: buildRetentionEntries(retentionRows) + .filter((item) => item.rank !== null) + .slice(0, RETENTION_MODEL_LIMIT), + country: buildCountryStats(countryRows), + } +} + +function buildStatsModelData( + modelParam: string, + modelRows: ModelStatMetric[], + countryRows: CountryTotalRow[], + providerParam?: string, + retentionRows: RetentionMetricRow[] = [], +): StatsModelData | null { + const normalized = modelRows.flatMap(normalizeStatRow) + if (normalized.length === 0) return null + + const model = resolveModelName(modelParam, normalized, providerParam) + if (!model) return null + + const modelScopedRows = normalized.filter((row) => row.model === model) + const earliest = Math.min(...normalized.map((row) => row.periodStart)) + const latest = Math.max(...normalized.map((row) => row.periodStart)) + const latestUpdate = Math.max(...modelScopedRows.map((row) => row.updatedAt)) + const window = getWindow("2M", earliest, latest) + const rankWindow = getWindow("1W", earliest, latest) + const currentRows = rowsForProduct(modelScopedRows, SITE_PRODUCT, window.start, window.end) + const previousRows = rowsForProduct(modelScopedRows, SITE_PRODUCT, window.previousStart, window.previousEnd) + const current = combineRowsForModel(model, currentRows) + const previous = combineRowsForModel(model, previousRows) + const rankPeers = aggregateByModelName(rowsForProduct(normalized, SITE_PRODUCT, rankWindow.start, rankWindow.end)) + .filter((item) => item.totalTokens > 0) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) + const previousRankPeers = aggregateByModelName( + rowsForProduct(normalized, SITE_PRODUCT, rankWindow.previousStart, rankWindow.previousEnd), + ) + .filter((item) => item.totalTokens > 0) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) + const windowPeers = aggregateByModelName(rowsForProduct(normalized, SITE_PRODUCT, window.start, window.end)) + .filter((item) => item.totalTokens > 0) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) + const rankIndex = rankPeers.findIndex((item) => item.model === model) + const rank = rankIndex >= 0 ? rankIndex + 1 : null + const previousRankIndex = previousRankPeers.findIndex((item) => item.model === model) + const peerRank = rankIndex >= 0 ? rankIndex + 1 : 1 + const totalTokens = windowPeers.reduce((sum, item) => sum + item.totalTokens, 0) + const peerTokens = rankPeers.reduce((sum, item) => sum + item.totalTokens, 0) + const weeklyRetention = buildRetentionEntries(retentionRows).find((item) => item.model === model) ?? null + + return { + updatedAt: Number.isFinite(latestUpdate) ? new Date(latestUpdate).toISOString() : null, + model, + slug: modelSlug(model), + provider: current.provider, + author: formatProvider(current.provider), + rank, + previousRank: previousRankIndex >= 0 ? previousRankIndex + 1 : null, + totalModels: windowPeers.length, + tokenShare: totalTokens > 0 ? round((current.totalTokens / totalTokens) * 100, 2) : 0, + tokenChange: percentChange(current.totalTokens, previous.totalTokens), + weeklyRetention, + totals: { + sessions: current.sessions, + uniqueUsers: current.uniqueUsers, + tokens: current.totalTokens, + cost: round(microcentsToDollars(current.totalCostMicrocents), 2), + tokensPerSession: current.sessions > 0 ? Math.round(current.totalTokens / current.sessions) : 0, + costPerSession: + current.sessions > 0 ? round(microcentsToDollars(current.totalCostMicrocents) / current.sessions, 4) : 0, + costPerMillion: costPerMillion(current.totalCostMicrocents, current.totalTokens), + cacheRatio: + current.inputTokens + current.cacheReadTokens > 0 + ? round((current.cacheReadTokens / (current.inputTokens + current.cacheReadTokens)) * 100, 1) + : 0, + }, + usage: buildModelUsage(currentRows, window, "2M"), + tokenMix: buildModelTokenMix(current), + country: buildCountryStats(countryRows), + peers: buildModelPeers(rankPeers, peerRank, peerTokens), + } +} + +function buildStatsLabData(providerParam: string, modelRows: ModelStatMetric[]): StatsLabData | null { + const normalized = modelRows.flatMap(normalizeStatRow) + if (normalized.length === 0) return null + + const provider = resolveProviderName(providerParam, normalized) + if (!provider) return null + + const providerRows = normalized.filter((row) => providerMatches(row.provider, provider)) + if (providerRows.length === 0) return null + + const earliest = Math.min(...normalized.map((row) => row.periodStart)) + const latest = Math.max(...normalized.map((row) => row.periodStart)) + const latestUpdate = Math.max(...providerRows.map((row) => row.updatedAt)) + const window = getWindow("2M", earliest, latest) + const currentRows = rowsForProduct(providerRows, SITE_PRODUCT, window.start, window.end) + const previousRows = rowsForProduct(providerRows, SITE_PRODUCT, window.previousStart, window.previousEnd) + const current = combineRowsForModel("", currentRows) + const previous = combineRowsForModel("", previousRows) + const allCurrent = aggregateByModel(rowsForProduct(normalized, SITE_PRODUCT, window.start, window.end)) + const totalTokens = allCurrent.reduce((sum, item) => sum + item.totalTokens, 0) + const models = aggregateByModel(currentRows) + .filter((item) => item.totalTokens > 0) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) + + return { + updatedAt: Number.isFinite(latestUpdate) ? new Date(latestUpdate).toISOString() : null, + provider, + author: formatProvider(provider), + tokenShare: totalTokens > 0 ? round((current.totalTokens / totalTokens) * 100, 2) : 0, + tokenChange: percentChange(current.totalTokens, previous.totalTokens), + totals: { + sessions: current.sessions, + tokens: current.totalTokens, + models: models.length, + }, + usage: buildModelUsage(currentRows, window, "2M"), + models: models.map((item) => ({ + model: item.model, + provider: item.provider, + author: formatProvider(item.provider), + tokens: item.totalTokens, + share: current.totalTokens > 0 ? round((item.totalTokens / current.totalTokens) * 100, 2) : 0, + slug: modelSlug(item.model), + })), + } +} + +function toComparisonEntry(data: StatsModelData | null): StatsModelComparisonEntry | null { + if (!data) return null + return { + updatedAt: data.updatedAt, + model: data.model, + slug: data.slug, + provider: data.provider, + author: data.author, + rank: data.rank, + previousRank: data.previousRank, + totalModels: data.totalModels, + tokenShare: data.tokenShare, + tokenChange: data.tokenChange, + weeklyRetention: data.weeklyRetention, + totals: data.totals, + usage: data.usage, + } +} + +function emptyStatsHomeData(): StatsHomeData { + return { + updatedAt: null, + usage: createUsageProductRecord(() => createRangeRecord(() => [])), + users: createUsageProductRecord(() => createRangeRecord(() => [])), + leaderboard: createUsageProductRecord(() => createRangeRecord(() => [])), + market: createRangeRecord(() => []), + tokenCost: createTokenProductRecord(() => []), + cacheRatio: createTokenProductRecord(() => []), + sessionCost: createTokenProductRecord(() => []), + retention: [], + country: [], + } +} + +export function buildRetentionEntries(rows: RetentionMetricRow[]): RetentionEntry[] { + const cohortDates = [...new Set(rows.map((row) => row.cohortDate))].toSorted().slice(-RETENTION_COHORT_WEEKS) + const aggregate = rows + .filter((row) => cohortDates.includes(row.cohortDate)) + .reduce>>((result, row) => { + const current = result.get(row.model) + result.set(row.model, { + model: row.model, + provider: current?.provider ?? row.provider, + eligibleUserWeeks: (current?.eligibleUserWeeks ?? 0) + row.eligibleUsers, + retainedUserWeeks: (current?.retainedUserWeeks ?? 0) + row.retainedUsers, + }) + return result + }, new Map()) + const entries = [...aggregate.values()].map((item) => ({ + ...item, + author: formatProvider(item.provider), + rate: item.eligibleUserWeeks > 0 ? round((item.retainedUserWeeks / item.eligibleUserWeeks) * 100, 1) : 0, + })) + const ranks = new Map( + entries + .filter((item) => item.eligibleUserWeeks >= RETENTION_MIN_ELIGIBLE_USER_WEEKS) + .toSorted( + (a, b) => b.rate - a.rate || b.eligibleUserWeeks - a.eligibleUserWeeks || a.model.localeCompare(b.model), + ) + .map((item, index) => [item.model, index + 1]), + ) + return entries + .map((item) => ({ ...item, rank: ranks.get(item.model) ?? null })) + .toSorted((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER)) +} + +function buildUsagePoints( + rows: StatMetricRow[], + product: UsageProduct, + range: UsageRange, + window: DateWindow, + rankWindow: DateWindow, + metric: "tokens" | "users" = "tokens", +) { + const modelOrder = aggregateByModelName(rowsForProduct(rows, product, rankWindow.start, rankWindow.end)) + .toSorted((a, b) => modelUsageValue(b, metric) - modelUsageValue(a, metric)) + .slice(0, TOP_MODEL_SEGMENT_LIMIT) + .map((item) => item.model) + + return createBuckets(window, range).map((bucket) => { + const bucketRows = aggregateByModelName(rowsForProduct(rows, product, bucket.start, bucket.end)) + const byModel = new Map(bucketRows.map((item) => [item.model, modelUsageValue(item, metric)])) + const segments = modelOrder.map((model) => ({ model, value: byModel.get(model) ?? 0 })) + const knownValue = segments.reduce((sum, item) => sum + item.value, 0) + const totalValue = bucketRows.reduce((sum, item) => sum + modelUsageValue(item, metric), 0) + return { + date: bucket.label, + segments: [ + ...segments.map((item) => ({ model: item.model, value: usagePointValue(item.value, metric) })), + { model: "Other", value: usagePointValue(Math.max(totalValue - knownValue, 0), metric) }, + ], + } + }) +} + +function modelUsageValue(item: ModelAggregate, metric: "tokens" | "users") { + if (metric === "users") return item.uniqueUsers + return item.totalTokens +} + +function usagePointValue(value: number, metric: "tokens" | "users") { + if (metric === "users") return value + return round(value / 1_000_000_000_000, 4) +} + +function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, rankWindow: DateWindow) { + const previous = new Map( + aggregateByModelName(rowsForProduct(rows, product, rankWindow.previousStart, rankWindow.previousEnd)).map( + (item) => [item.model, item.totalTokens], + ), + ) + + return aggregateByModelName(rowsForProduct(rows, product, rankWindow.start, rankWindow.end)) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) + .slice(0, 18) + .map((item, index) => ({ + model: item.model, + provider: item.provider, + author: formatProvider(item.provider), + tokens: Math.round(item.totalTokens / 1_000_000_000), + change: leaderboardChange(item.totalTokens, previous.get(item.model) ?? 0), + rank: index + 1, + })) +} + +function buildMarketShare(rows: StatMetricRow[], product: UsageProduct, range: UsageRange, window: DateWindow) { + const providerOrder = aggregateByProvider(rowsForProduct(rows, product, window.start, window.end)) + .filter((item) => item.provider !== "unknown") + .toSorted((a, b) => b.tokens - a.tokens || a.provider.localeCompare(b.provider)) + .slice(0, 8) + .map((item) => item.provider) + + return createBuckets(window, range).flatMap((bucket) => { + const total = aggregateByProvider(rowsForProduct(rows, product, bucket.start, bucket.end)) + const totalTokens = total.reduce((sum, item) => sum + item.tokens, 0) + if (totalTokens === 0) return [] + + const byProvider = new Map(total.map((item) => [item.provider, item.tokens])) + const authors = providerOrder.map((provider) => ({ provider, tokens: byProvider.get(provider) ?? 0 })) + const knownTokens = authors.reduce((sum, item) => sum + item.tokens, 0) + const withOther = [...authors, { provider: "Other", tokens: Math.max(totalTokens - knownTokens, 0) }].filter( + (item) => item.tokens > 0, + ) + + return [ + { + date: bucket.label, + total: round(totalTokens / 1_000_000_000_000, 6), + authors: withOther.map((item) => ({ + author: item.provider === "Other" ? "Other" : formatProvider(item.provider), + share: round((item.tokens / totalTokens) * 100, 1), + tokens: round(item.tokens / 1_000_000_000_000, 6), + })), + }, + ] + }) +} + +function buildCountryStats(rows: CountryTotalRow[]) { + const countries = rows + .filter((item) => item.tokens > 0 && item.country !== "AQ") + .toSorted((a, b) => b.tokens - a.tokens) + const totalTokens = countries.reduce((sum, item) => sum + item.tokens, 0) + if (totalTokens === 0) return [] + + return countries.map((item, index) => ({ + country: item.country, + continent: item.continent, + tokens: round(item.tokens / 1_000_000_000_000, 4), + share: round((item.tokens / totalTokens) * 100, 1), + rank: index + 1, + })) +} + +function buildTokenCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { + return topModelsByUsage(rows, product, window) + .flatMap((item) => { + const total = costPerMillion(item.totalCostMicrocents, item.totalTokens) + return [ + { + model: item.model, + total, + input: costPerMillion(item.inputCostMicrocents, item.inputTokens), + output: costPerMillion(item.outputCostMicrocents, item.outputTokens + item.reasoningTokens), + cached: costPerMillion(item.inputCostMicrocents, item.inputTokens + item.cacheReadTokens), + }, + ] + }) + .toSorted((a, b) => a.total - b.total) +} + +function buildCacheRatio(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { + return topModelsByUsage(rows, product, window) + .flatMap((item) => { + const total = item.inputTokens + item.cacheReadTokens + if (total === 0) return [] + return [ + { + model: item.model, + ratio: round((item.cacheReadTokens / total) * 100, 1), + cached: round(item.cacheReadTokens / 1_000_000_000, 1), + uncached: round(item.inputTokens / 1_000_000_000, 1), + total: round(total / 1_000_000_000, 1), + }, + ] + }) + .toSorted((a, b) => b.ratio - a.ratio || b.cached - a.cached) +} + +function buildSessionCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { + return topModelsByUsage(rows, product, window) + .flatMap((item) => { + if (item.sessions === 0) return [] + const cost = round(microcentsToDollars(item.totalCostMicrocents) / item.sessions, 4) + if (cost === 0) return [] + return [{ model: item.model, cost, tokens: Math.round(item.totalTokens / item.sessions) }] + }) + .toSorted((a, b) => a.cost - b.cost) +} + +function topModelsByUsage(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { + return aggregateByModel(rowsForProduct(rows, product, window.start, window.end)) + .toSorted((a, b) => b.totalTokens - a.totalTokens) + .slice(0, METRIC_MODEL_LIMIT) +} + +function buildModelUsage(rows: StatMetricRow[], window: DateWindow, range: UsageRange) { + return createBuckets(window, range).map((bucket) => { + const aggregate = combineRowsForModel( + "", + rows.filter((row) => row.periodStart >= bucket.start && row.periodStart < bucket.end), + ) + return { + date: bucket.label, + tokens: aggregate.totalTokens, + users: aggregate.uniqueUsers, + sessions: aggregate.sessions, + cost: round(microcentsToDollars(aggregate.totalCostMicrocents), 2), + } + }) +} + +function buildModelTokenMix(aggregate: ModelAggregate): ModelMixEntry[] { + const items = [ + { label: "Input", tokens: aggregate.inputTokens }, + { label: "Output", tokens: aggregate.outputTokens }, + { label: "Reasoning", tokens: aggregate.reasoningTokens }, + { label: "Cached", tokens: aggregate.cacheReadTokens }, + ].filter((item) => item.tokens > 0) + const total = items.reduce((sum, item) => sum + item.tokens, 0) + if (total === 0) return [] + return items.map((item) => ({ ...item, share: round((item.tokens / total) * 100, 1) })) +} + +function buildModelPeers(peers: ModelAggregate[], rank: number, totalTokens: number): ModelPeerEntry[] { + const start = Math.max(0, Math.min(rank - 5, Math.max(peers.length - 10, 0))) + return peers.slice(start, start + 10).map((item, index) => ({ + model: item.model, + provider: item.provider, + author: formatProvider(item.provider), + rank: start + index + 1, + tokens: item.totalTokens, + share: totalTokens > 0 ? round((item.totalTokens / totalTokens) * 100, 2) : 0, + slug: modelSlug(item.model), + })) +} + +function rowsForProduct( + rows: T[], + product: UsageProduct, + start: number, + end: number, +) { + const windowRows = rows.filter((row) => row.periodStart >= start && row.periodStart < end) + if (product === SITE_PRODUCT) return windowRows.filter((row) => row.tier === "Go" || row.tier === "Free") + if (product !== "All Users") return windowRows.filter((row) => row.tier === product) + + const allRows = windowRows.filter((row) => row.tier === "all") + if (allRows.length > 0) return allRows + return windowRows.filter((row) => row.tier !== "all") +} + +function aggregateByModel(rows: StatMetricRow[]) { + return Object.values( + rows.reduce>((result, row) => { + const key = modelKey(row.provider, row.model) + result[key] = combineModelAggregate(result[key], row) + return result + }, {}), + ) +} + +function aggregateByModelName(rows: StatMetricRow[]) { + return Object.values( + rows.reduce>((result, row) => { + result[row.model] = combineModelAggregate(result[row.model], row) + return result + }, {}), + ) +} + +function aggregateByProvider(rows: { provider: string; totalTokens: number }[]) { + return Object.values( + rows.reduce>((result, row) => { + result[row.provider] = { + provider: row.provider, + tokens: (result[row.provider]?.tokens ?? 0) + row.totalTokens, + } + return result + }, {}), + ) +} + +function combineRowsForModel(model: string, rows: StatMetricRow[]): ModelAggregate { + const aggregate = rows.reduce( + (result, row) => combineModelAggregate(result, row), + undefined, + ) + if (aggregate) return { ...aggregate, model: model || aggregate.model } + return { + model, + provider: "unknown", + sessions: 0, + uniqueUsers: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + inputCostMicrocents: 0, + outputCostMicrocents: 0, + totalCostMicrocents: 0, + } +} + +function combineModelAggregate(current: ModelAggregate | undefined, row: StatMetricRow): ModelAggregate { + return { + model: row.model, + provider: row.provider, + sessions: (current?.sessions ?? 0) + row.sessions, + uniqueUsers: (current?.uniqueUsers ?? 0) + row.uniqueUsers, + inputTokens: (current?.inputTokens ?? 0) + row.inputTokens, + outputTokens: (current?.outputTokens ?? 0) + row.outputTokens, + reasoningTokens: (current?.reasoningTokens ?? 0) + row.reasoningTokens, + cacheReadTokens: (current?.cacheReadTokens ?? 0) + row.cacheReadTokens, + totalTokens: (current?.totalTokens ?? 0) + row.totalTokens, + inputCostMicrocents: (current?.inputCostMicrocents ?? 0) + row.inputCostMicrocents, + outputCostMicrocents: (current?.outputCostMicrocents ?? 0) + row.outputCostMicrocents, + totalCostMicrocents: (current?.totalCostMicrocents ?? 0) + row.totalCostMicrocents, + } +} + +function getWindow(range: UsageRange, earliest: number, latest: number): DateWindow { + const end = latest + DAY_MS + const start = Math.max( + earliest, + range === "1D" + ? latest + : range === "1W" + ? latest - 6 * DAY_MS + : range === "2W" + ? latest - 13 * DAY_MS + : range === "1M" + ? latest - 27 * DAY_MS + : range === "2M" + ? latest - 55 * DAY_MS + : range === "3M" + ? latest - 89 * DAY_MS + : range === "YTD" + ? Date.UTC(new Date(latest).getUTCFullYear(), 0, 1) + : earliest, + ) + const duration = end - start + return { start, end, previousStart: start - duration, previousEnd: start } +} + +function createBuckets(window: DateWindow, range: UsageRange): Bucket[] { + const span = Math.max(window.end - window.start, DAY_MS) + const count = + range === "1D" + ? 1 + : range === "1W" || range === "2W" || range === "1M" || range === "2M" || range === "3M" + ? Math.ceil(span / DAY_MS) + : Math.max(1, Math.min(7, Math.ceil(span / DAY_MS))) + const size = span / count + return Array.from({ length: count }, (_, index) => { + const start = window.start + index * size + const end = index === count - 1 ? window.end : window.start + (index + 1) * size + return { start, end, label: formatBucketLabel(start, end, range) } + }) +} + +function createUsageProductRecord(value: (product: UsageProduct) => T): Record { + return { + "All Users": value("All Users"), + Zen: value("Zen"), + Go: value("Go"), + Enterprise: value("Enterprise"), + } +} + +function createTokenProductRecord(value: (product: TokenProduct) => T): Record { + return { + Zen: value("Zen"), + Go: value("Go"), + Enterprise: value("Enterprise"), + } +} + +function createRangeRecord(value: (range: UsageRange) => T): Record { + return { + "1D": value("1D"), + "1W": value("1W"), + "2W": value("2W"), + "1M": value("1M"), + "2M": value("2M"), + "3M": value("3M"), + YTD: value("YTD"), + ALL: value("ALL"), + } +} + +function normalizeStatRow(row: ModelStatMetric): StatMetricRow[] { + const periodStart = periodKeyTime(row.periodKey) + const updatedAt = dateTime(row.updatedAt) + if (!Number.isFinite(periodStart) || !Number.isFinite(updatedAt)) return [] + return [ + { + ...row, + periodStart, + updatedAt, + tier: normalizeTier(row.tier), + provider: statProvider(row.model, undefined, row.provider) || "unknown", + model: row.model || "unknown", + }, + ] +} + +function modelRowsWindow(rows: ModelStatMetric[], range: UsageRange): DateWindow | undefined { + const periods = rows.map((row) => periodKeyTime(row.periodKey)).filter(Number.isFinite) + if (periods.length === 0) return undefined + return getWindow(range, Math.min(...periods), Math.max(...periods)) +} + +function dateTime(value: Date | string) { + return (value instanceof Date ? value : new Date(value)).getTime() +} + +function periodKey(value: number) { + return new Date(value).toISOString().slice(0, 10) +} + +function periodKeyTime(value: string) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) return Number.NaN + return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) +} + +function formatBucketLabel(start: number, _end: number, range: UsageRange) { + const date = new Date(start) + if (range === "YTD") return months[date.getUTCMonth()] + if (range === "ALL") + return date.getUTCFullYear() === new Date().getUTCFullYear() + ? months[date.getUTCMonth()] + : String(date.getUTCFullYear()) + return formatDay(start) +} + +function formatDay(value: number) { + const date = new Date(value) + return `${months[date.getUTCMonth()]} ${date.getUTCDate()}` +} + +function formatProvider(provider: string) { + const known: Record = { + anthropic: "Anthropic", + deepseek: "DeepSeek", + google: "Google", + minimax: "MiniMax", + meta: "Meta", + moonshot: "Moonshot", + moonshotai: "Moonshot", + nvidia: "NVIDIA", + opencode: "opencode", + openai: "OpenAI", + qwen: "Qwen", + tencent: "Tencent", + xai: "xAI", + xiaomi: "Xiaomi", + zhipu: "Zhipu", + zhipuai: "Zhipu", + } + const normalized = provider.toLowerCase().replace(/[^a-z0-9]/g, "") + return known[normalized] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) +} + +function resolveModelName(modelParam: string, rows: StatMetricRow[], providerParam?: string) { + const input = modelParam.trim() + if (!input) return undefined + const normalizedInput = input.toLowerCase() + const inputSlug = modelSlug(input) + const candidates = providerParam + ? aggregateByModel(rows).filter((item) => providerMatches(item.provider, providerParam)) + : aggregateByModelName(rows) + return candidates + .filter((item) => item.model.toLowerCase() === normalizedInput || modelSlug(item.model) === inputSlug) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model))[0]?.model +} + +function resolveModelProvider(model: string, rows: StatMetricRow[], providerParam?: string) { + return aggregateByModel(rows) + .filter((item) => item.model === model && (!providerParam || providerMatches(item.provider, providerParam))) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.provider.localeCompare(b.provider))[0]?.provider +} + +function providerMatches(provider: string, providerParam: string) { + return providerSlug(provider) === providerSlug(providerParam) +} + +function resolveProviderName(providerParam: string, rows: StatMetricRow[]) { + const input = providerParam.trim() + if (!input) return undefined + return aggregateByModel(rows) + .filter((item) => providerMatches(item.provider, input)) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.provider.localeCompare(b.provider))[0]?.provider +} + +export function modelSlug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") +} + +function modelKey(provider: string, model: string) { + return `${provider}\u0000${model}` +} + +function providerSlug(value: string) { + const slug = modelSlug(value) + const aliases: Record = { + alibaba: "qwen", + moonshotai: "moonshot", + qwen: "qwen", + zhipuai: "zhipu", + } + return aliases[slug] ?? slug +} + +function costPerMillion(costMicrocents: number, tokens: number) { + if (tokens <= 0 || costMicrocents <= 0) return 0 + return round((microcentsToDollars(costMicrocents) / tokens) * TOKEN_SCALE, 2) +} + +function microcentsToDollars(value: number) { + return value * DOLLARS_PER_MICROCENT +} + +function percentChange(current: number, previous: number) { + if (previous <= 0) return current > 0 ? 100 : 0 + return Math.round(((current - previous) / previous) * 100) +} + +function leaderboardChange(current: number, previous: number) { + if (current <= 0) return 0 + if (previous <= 0 || current >= previous * LEADERBOARD_CHANGE_MIN_MULTIPLE) return null + return percentChange(current, previous) +} + +function round(value: number, digits: number) { + return Number(value.toFixed(digits)) +} diff --git a/packages/web/src/content/docs/bs/acp.mdx b/packages/web/src/content/docs/bs/acp.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b4065b92672707373cdfea722c07694f5095514c --- /dev/null +++ b/packages/web/src/content/docs/bs/acp.mdx @@ -0,0 +1,159 @@ +--- +title: ACP podrška +description: Koristite OpenCode u bilo kojem uređivaču kompatibilnom sa ACP. +--- + +OpenCode podržava [Agent Client Protocol](https://agentclientprotocol.com) (ACP), što vam omogućava da ga koristite direktno u kompatibilnim uređivačima i IDE-ovima. + +:::tip +Za listu uređivača i alata koji podržavaju ACP, pogledajte [ACP izvještaj o napretku](https://zed.dev/blog/acp-progress-report#available-now). +::: + +ACP je otvoreni protokol koji standardizira komunikaciju između uređivača koda i AI coding agenata. + +--- + +## Konfiguracija + +Da biste koristili OpenCode putem ACP-a, konfigurirajte svoj uređivač da pokrene naredbu `opencode acp`. + +Naredba pokreće OpenCode kao ACP-kompatibilan podproces koji komunicira sa vašim uređivačem preko JSON-RPC-a kroz stdio. + +Ispod su primjeri za popularne uređivače koji podržavaju ACP. + +--- + +### Zed + +Instalirajte OpenCode iz [Zed ACP registra](https://zed.dev/docs/ai/external-agents#registry) pokretanjem naredbe `zed: acp registry` u komandnoj paleti. + +Ako umjesto toga želite koristiti prilagođenu OpenCode izvršnu datoteku, dodajte je u svoju [Zed](https://zed.dev) konfiguraciju (`~/.config/zed/settings.json`): + +```json title="~/.config/zed/settings.json" +{ + "agent_servers": { + "OpenCode": { + "type": "custom", + "command": "opencode", + "args": ["acp"] + } + } +} +``` + +Da biste ga otvorili, koristite akciju `agent: new thread` u **Command Palette**. + +Također možete vezati prečicu na tastaturi uređivanjem vašeg `keymap.json`: + +```json title="keymap.json" +[ + { + "bindings": { + "cmd-alt-o": [ + "agent::NewExternalAgentThread", + { + "agent": { + "custom": { + "name": "OpenCode", + "command": { + "command": "opencode", + "args": ["acp"] + } + } + } + } + ] + } + } +] +``` + +--- + +### JetBrains IDE-ovi + +Dodajte u svoj [JetBrains IDE](https://www.jetbrains.com/) `acp.json` prema [dokumentaciji](https://www.jetbrains.com/help/ai-assistant/acp.html): + +```json title="acp.json" +{ + "agent_servers": { + "OpenCode": { + "command": "/absolute/path/bin/opencode", + "args": ["acp"] + } + } +} +``` + +Da biste ga otvorili, koristite novog "OpenCode" agenta u AI Chat agent selektoru. + +--- + +### Avante.nvim + +Dodajte u svoju [Avante.nvim](https://github.com/yetone/avante.nvim) konfiguraciju: + +```lua +{ + acp_providers = { + ["opencode"] = { + command = "opencode", + args = { "acp" } + } + } +} +``` + +Ako trebate proslijediti varijable okruženja: + +```lua {6-8} +{ + acp_providers = { + ["opencode"] = { + command = "opencode", + args = { "acp" }, + env = { + OPENCODE_API_KEY = os.getenv("OPENCODE_API_KEY") + } + } + } +} +``` + +--- + +### CodeCompanion.nvim + +Da koristite OpenCode kao ACP agenta u [CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim), dodajte sljedeće u svoju Neovim konfiguraciju: + +```lua +require("codecompanion").setup({ + interactions = { + chat = { + adapter = { + name = "opencode", + model = "claude-sonnet-4", + }, + }, + }, +}) +``` + +Ova konfiguracija postavlja CodeCompanion da koristi OpenCode kao ACP chat agenta. + +Ako trebate proslijediti varijable okruženja (kao što je `OPENCODE_API_KEY`), pogledajte [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key) u dokumentaciji CodeCompanion.nvim. + +## Podržane funkcije + +OpenCode radi isto kroz ACP kao i u terminalu. Podržane su sve funkcije: + +:::note +Neke ugrađene komande kao što su `/undo` i `/redo` trenutno nisu podržane. +::: + +- Ugrađeni alati (operacije sa datotekama, naredbe terminala, itd.) +- Prilagođeni alati i slash komande +- MCP serveri konfigurisani u vašoj OpenCode konfiguraciji +- Pravila specifična za projekat `AGENTS.md` +- Prilagođeni formateri i linteri +- Agenti i sistem dozvola diff --git a/packages/web/src/content/docs/bs/agents.mdx b/packages/web/src/content/docs/bs/agents.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a2e211b19adda9db82463bef483e90162f8a872a --- /dev/null +++ b/packages/web/src/content/docs/bs/agents.mdx @@ -0,0 +1,754 @@ +--- +title: Agenti +description: Konfigurirajte i koristite specijalizirane agente. +--- + +Agenti su specijalizirani AI asistenti koji se mogu konfigurirati za specifične zadatke i tokove posla. Oni vam omogućavaju da kreirate fokusirane alate sa prilagođenim upitima, modelima i pristupom alatima. + +:::tip +Koristite agenta plana za analizu koda i pregled prijedloga bez ikakvih promjena koda. +::: + +Možete se prebacivati ​​između agenata tokom sesije ili ih pozvati spominjanjem `@`. + +--- + +## Vrste + +Postoje dvije vrste agenata u OpenCode: primarni agenti i podagenti. + +--- + +### Primarni agenti + +Primarni agenti su glavni pomoćnici s kojima direktno komunicirate. Možete se kretati kroz njih pomoću tipke **Tab** ili vašeg konfigurisanog povezivanja tipki `switch_agent`. Ovi agenti vode vaš glavni razgovor. Pristup alatima se konfiguriše putem dozvola — na primjer, Build ima omogućene sve alate dok je Plan ograničen. + +:::tip +Možete koristiti tipku **Tab** za prebacivanje između primarnih agenata tokom sesije. +::: + +OpenCode dolazi sa dva ugrađena primarna agenta, **Build** i **Plan**. Pogledat ćemo ih u nastavku. + +--- + +### Subagenti + +Subagenti su specijalizovani pomoćnici koje primarni agenti mogu pozvati za određene zadatke. Možete ih i ručno pozvati **@ spominjanjem** u svojim porukama. + +OpenCode dolazi sa tri ugrađena subagenta, **General**, **Explore** i **Scout**. Ovo ćemo pogledati u nastavku. + +--- + +## Ugrađeni + +OpenCode dolazi sa dva ugrađena primarna agenta i tri ugrađena subagenta. + +--- + +### Build agent + +_Režim_: `primary` + +Build je **podrazumevani** primarni agent sa svim omogućenim alatima. Ovo je standardni agent za razvojni rad gdje vam je potreban pun pristup operacijama datoteka i sistemskim komandama. + +--- + +### Plan agent + +_Režim_: `primary` + +Ograničeni agent dizajniran za planiranje i analizu. Koristimo sistem dozvola kako bismo vam pružili veću kontrolu i spriječili neželjene promjene. +Prema zadanim postavkama, sve sljedeće je postavljeno na `ask`: + +- `file edits`: Sva upisivanja, zakrpe i uređivanja +- `bash`: Sve bash komande + +Ovaj agent je koristan kada želite da LLM analizira kod, predloži promjene ili kreira planove bez stvarnih modifikacija vaše baze koda. + +--- + +### General agent + +_Režim_: `subagent` + +Agent opće namjene za istraživanje složenih pitanja i izvršavanje zadataka u više koraka. Ima potpuni pristup alatima (osim todo), tako da može mijenjati fajlove kada je to potrebno. Koristite ovo za paralelno pokretanje više jedinica rada. + +--- + +### Explore agent + +_Režim_: `subagent` + +Brzi agent samo za čitanje za istraživanje kodnih baza. Nije moguće mijenjati fajlove. Koristite ovo kada trebate brzo pronaći datoteke po uzorku, pretražiti kod za ključne riječi ili odgovoriti na pitanja o bazi kodova. + +--- + +### Scout agent + +_Režim_: `subagent` + +Agent samo za čitanje za istraživanje eksterne dokumentacije i zavisnosti. Koristite ga kada trebate klonirati repozitorij zavisnosti u OpenCode-ov upravljani cache, pregledati izvorni kod biblioteke ili uporediti lokalni kod sa upstream implementacijama bez mijenjanja vašeg radnog prostora. + +--- + +### Compaction agent + +_Režim_: `primary` + +Skriveni sistemski agent koji sažima dugi kontekst u manji sažetak. Pokreće se automatski kada je potrebno i ne može se odabrati u korisničkom interfejsu. + +--- + +### Title agent + +_Režim_: `primary` + +Skriveni sistemski agent koji generiše kratke naslove sesija. Pokreće se automatski i ne može se odabrati u korisničkom interfejsu. + +--- + +### Summary agent + +_Režim_: `primary` + +Skriveni sistemski agent koji kreira sažetke sesije. Pokreće se automatski i ne može se odabrati u korisničkom interfejsu. + +--- + +## Korištenje + +1. Za primarne agente, koristite taster **Tab** za kretanje kroz njih tokom sesije. Također možete koristiti svoju konfiguriranu vezu tipke `switch_agent`. + +2. Subagenti se mogu pozvati: + - **Automatski** od strane primarnih agenata za specijalizovane zadatke na osnovu njihovih opisa. + - Ručno **@ spominjanjem** subagenta u vašoj poruci. Na primjer. + + ```txt frame="none" + @general help me search for this function + ``` + +3. **Navigacija između sesija**: Kada subagenti kreiraju vlastite podređene sesije, možete se kretati između roditeljske sesije i svih podređenih sesija koristeći: + - **\+Right** (ili vaša konfigurirana `session_child_cycle` veza) za kretanje naprijed kroz roditelj → dijete1 → dijete2 → ... → roditelj + - **\+Left** (ili vaše konfigurirano povezivanje tipki `session_child_cycle_reverse`) za kretanje unazad kroz roditelj ← dijete1 ← dijete2 ← ... ← roditelj + + Ovo vam omogućava neprimetno prebacivanje između glavnog razgovora i rada specijalizovanog podagenta. + +--- + +## Konfiguracija + +Možete prilagoditi ugrađene agente ili kreirati vlastite kroz konfiguraciju. Agenti se mogu konfigurisati na dva načina: + +--- + +### JSON + +Konfigurirajte agente u svom konfiguracijskom fajlu `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "mode": "primary", + "model": "anthropic/claude-sonnet-4-20250514", + "prompt": "{file:./prompts/build.txt}", + "tools": { + "write": true, + "edit": true, + "bash": true + } + }, + "plan": { + "mode": "primary", + "model": "anthropic/claude-haiku-4-20250514", + "tools": { + "write": false, + "edit": false, + "bash": false + } + }, + "code-reviewer": { + "description": "Reviews code for best practices and potential issues", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-20250514", + "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", + "tools": { + "write": false, + "edit": false + } + } + } +} +``` + +--- + +### Markdown + +Također možete definirati agente koristeći markdown datoteke. Stavite ih u: + +- Globalno: `~/.config/opencode/agents/` +- Po projektu: `.opencode/agents/` + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Reviews code for quality and best practices +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +temperature: 0.1 +tools: + write: false + edit: false + bash: false +--- + +You are in code review mode. Focus on: + +- Code quality and best practices +- Potential bugs and edge cases +- Performance implications +- Security considerations + +Provide constructive feedback without making direct changes. +``` + +Ime markdown datoteke postaje ime agenta. Na primjer, `review.md` kreira `review` agenta. + +--- + +## Opcije + +Pogledajmo ove opcije konfiguracije detaljno. + +--- + +### Opis + +Koristite opciju `description` da pružite kratak opis onoga što agent radi i kada ga koristiti. + +```json title="opencode.json" +{ + "agent": { + "review": { + "description": "Reviews code for best practices and potential issues" + } + } +} +``` + +Ovo je **obavezna** opcija konfiguracije. + +--- + +### Temperatura + +Kontrolišite slučajnost i kreativnost odgovora LLM-a pomoću `temperature` konfiguracije. + +Niže vrijednosti čine odgovore fokusiranijim i determinističkim, dok više vrijednosti povećavaju kreativnost i varijabilnost. + +```json title="opencode.json" +{ + "agent": { + "plan": { + "temperature": 0.1 + }, + "creative": { + "temperature": 0.8 + } + } +} +``` + +Vrijednosti temperature se obično kreću od 0.0 do 1.0: + +- **0.0-0.2**: Vrlo fokusirani i deterministički odgovori, idealni za analizu i planiranje koda +- **0.3-0.5**: Uravnoteženi odgovori sa malo kreativnosti, dobro za opšte razvojne zadatke +- **0.6-1.0**: Kreativniji i raznovrsniji odgovori, korisni za razmišljanje i istraživanje + +```json title="opencode.json" +{ + "agent": { + "analyze": { + "temperature": 0.1, + "prompt": "{file:./prompts/analysis.txt}" + }, + "build": { + "temperature": 0.3 + }, + "brainstorm": { + "temperature": 0.7, + "prompt": "{file:./prompts/creative.txt}" + } + } +} +``` + +Ako temperatura nije navedena, OpenCode koristi standardne postavke specifične za model; obično 0 za većinu modela, 0.55 za Qwen modele. + +--- + +### Maksimalan broj koraka + +Kontrolirajte maksimalni broj iteracija agenta koje agent može izvesti prije nego što bude prisiljen da odgovori samo tekstom. Ovo omogućava korisnicima koji žele kontrolirati troškove da postave ograničenje na akcije agenta. + +Ako ovo nije postavljeno, agent će nastaviti iterirati sve dok model ne odluči da se zaustavi ili korisnik ne prekine sesiju. + +```json title="opencode.json" +{ + "agent": { + "quick-thinker": { + "description": "Fast reasoning with limited iterations", + "prompt": "You are a quick thinker. Solve problems with minimal steps.", + "steps": 5 + } + } +} +``` + +Kada se dostigne ograničenje, agent prima poseban sistemski prompt koji ga upućuje da odgovori sa rezimeom svog rada i preporučenim preostalim zadacima. + +:::caution +Naslijeđeno polje `maxSteps` je zastarjelo. Umjesto toga koristite `steps`. +::: + +--- + +### Onemogućavanje + +Postavite na `true` da onemogućite agenta. + +```json title="opencode.json" +{ + "agent": { + "review": { + "disable": true + } + } +} +``` + +--- + +### Upit + +Navedite prilagođenu sistemsku prompt datoteku za ovog agenta sa `prompt` konfiguracijom. Datoteka s promptom treba da sadrži upute specifične za svrhu agenta. + +```json title="opencode.json" +{ + "agent": { + "review": { + "prompt": "{file:./prompts/code-review.txt}" + } + } +} +``` + +Ova putanja je relativna u odnosu na mjesto gdje se nalazi konfiguracijski fajl. Dakle, ovo radi i za globalnu OpenCode konfiguraciju i za konfiguraciju specifične za projekat. + +--- + +### Model + +Koristite `model` konfiguraciju da nadjačate model za ovog agenta. Korisno za korištenje različitih modela optimiziranih za različite zadatke. Na primjer, brži model za planiranje, sposobniji model za implementaciju. + +:::tip +Ako ne navedete model, primarni agenti koriste [model globalno konfiguriran](/docs/config#models) dok će podagenti koristiti model primarnog agenta koji je pozvao subagenta. +::: + +```json title="opencode.json" +{ + "agent": { + "plan": { + "model": "anthropic/claude-haiku-4-20250514" + } + } +} +``` + +ID modela u vašoj OpenCode konfiguraciji koristi format `provider/model-id`. Na primjer, ako koristite [OpenCode Zen](/docs/zen), koristili biste `opencode/gpt-5.1-codex` za GPT 5.1 Codex. + +--- + +### Alati + +Kontrolirajte koji su alati dostupni u ovom agentu koristeći konfiguraciju `tools`. Možete omogućiti ili onemogućiti određene alate tako što ćete ih postaviti na `true` ili `false`. + +```json title="opencode.json" {3-6,9-12} +{ + "$schema": "https://opencode.ai/config.json", + "tools": { + "write": true, + "bash": true + }, + "agent": { + "plan": { + "tools": { + "write": false, + "bash": false + } + } + } +} +``` + +:::note +Konfiguracija specifična za agenta poništava globalnu konfiguraciju. +::: + +Također možete koristiti zamjenske znakove za kontrolu više alata odjednom. Na primjer, da onemogućite sve alate sa MCP servera: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "readonly": { + "tools": { + "mymcp_*": false, + "write": false, + "edit": false + } + } + } +} +``` + +[Saznajte više o alatima](/docs/tools). + +--- + +### Dozvole + +Možete konfigurirati dozvole za upravljanje radnjama koje agent može poduzeti. Trenutno se dozvole za alate `edit`, `bash` i `webfetch` mogu konfigurirati na: + +- `"ask"` — Zatražite odobrenje prije pokretanja alata +- `"allow"` — Dozvoli sve operacije bez odobrenja +- `"deny"` — Onemogućite alat + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny" + } +} +``` + +Možete nadjačati ove dozvole po agentu. + +```json title="opencode.json" {3-5,8-10} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny" + }, + "agent": { + "build": { + "permission": { + "edit": "ask" + } + } + } +} +``` + +Također možete postaviti dozvole u Markdown agentima. + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Code review without edits +mode: subagent +permission: + edit: deny + bash: + "*": ask + "git diff": allow + "git log*": allow + "grep *": allow + webfetch: deny +--- + +Only analyze code and suggest changes. +``` + +Možete postaviti dozvole za određene bash komande. + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "git push": "ask", + "grep *": "allow" + } + } + } + } +} +``` + +Ovo može koristiti glob uzorak. + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "git *": "ask" + } + } + } + } +} +``` + +Također možete koristiti zamjenski znak `*` za kontrolu dozvola za sve komande. +Budući da posljednje podudarno pravilo ima prednost, prvo postavite zamjenski znak `*`, a zatim navedena pravila. + +```json title="opencode.json" {8} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git status *": "allow" + } + } + } + } +} +``` + +[Saznajte više o dozvolama](/docs/permissions). + +--- + +### Način rada + +Kontrolirajte način rada agenta koristeći konfiguraciju `mode`. Opcija `mode` se koristi da specificira kako se agent može koristiti. + +```json title="opencode.json" +{ + "agent": { + "review": { + "mode": "subagent" + } + } +} +``` + +Opcija `mode` se može postaviti na `primary`, `subagent` ili `all`. Ako `mode` nije specificirano, podrazumevano je `all`. + +--- + +### Skriveno + +Sakrij podagenta iz `@` menija za automatsko dovršavanje sa `hidden: true`. Korisno za interne podagente koje bi drugi agenti trebali programski pozvati samo preko Task alata. + +```json title="opencode.json" +{ + "agent": { + "internal-helper": { + "mode": "subagent", + "hidden": true + } + } +} +``` + +Ovo utiče samo na vidljivost korisnika u meniju za automatsko dovršavanje. Skriveni agenti se i dalje mogu pozvati od strane modela putem alata Task ako dozvole to dozvoljavaju. + +:::note +Odnosi se samo na `mode: subagent` agente. +::: + +--- + +### Dozvole zadataka + +Kontrolirajte koje podagente agent može pozvati preko Task alata sa `permission.task`. Koristi glob uzorke za fleksibilno uparivanje. + +```json title="opencode.json" +{ + "agent": { + "orchestrator": { + "mode": "primary", + "permission": { + "task": { + "*": "deny", + "orchestrator-*": "allow", + "code-reviewer": "ask" + } + } + } + } +} +``` + +Kada se postavi na `deny`, subagent se u potpunosti uklanja iz opisa alata za zadatak, tako da ga model neće pokušati pozvati. + +:::tip +Pravila se procjenjuju po redoslijedu i **posljednje odgovarajuće pravilo pobjeđuje**. U gornjem primjeru, `orchestrator-planner` odgovara i `*` (deny) i `orchestrator-*` (allow), ali pošto `orchestrator-*` dolazi nakon `*`, rezultat je `allow`. +::: + +:::tip +Korisnici uvijek mogu pozvati bilo kojeg subagenta direktno preko `@` menija za autodovršavanje, čak i ako bi dozvole za zadatak agenta to uskratile. +::: + +--- + +### Boja + +Prilagodite vizualni izgled agenta u korisničkom sučelju s opcijom `color`. Ovo utiče na to kako se agent pojavljuje u interfejsu. + +Koristite važeću heksadecimalnu boju (npr. `#FF5733`) ili boju teme: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`. + +```json title="opencode.json" +{ + "agent": { + "creative": { + "color": "#ff6b6b" + }, + "code-reviewer": { + "color": "accent" + } + } +} +``` + +--- + +### Top P + +Kontrolirajte raznolikost odgovora s opcijom `top_p`. Alternativa temperaturi za kontrolu nasumice. + +```json title="opencode.json" +{ + "agent": { + "brainstorm": { + "top_p": 0.9 + } + } +} +``` + +Vrijednosti se kreću od 0.0 do 1.0. Niže vrijednosti su više fokusirane, više vrijednosti raznovrsnije. + +--- + +### Dodatno + +Sve druge opcije koje navedete u konfiguraciji agenta će biti **direktno proslijeđene** dobavljaču kao opcije modela. Ovo vam omogućava da koristite karakteristike i parametre specifične za provajdera. + +Na primjer, sa OpenAI-jevim modelima rezonovanja, možete kontrolisati napor rasuđivanja: + +```json title="opencode.json" {6,7} +{ + "agent": { + "deep-thinker": { + "description": "Agent that uses high reasoning effort for complex problems", + "model": "openai/gpt-5", + "reasoningEffort": "high", + "textVerbosity": "low" + } + } +} +``` + +Ove dodatne opcije su specifične za model i dobavljača. U dokumentaciji vašeg provajdera provjerite dostupne parametre. + +:::tip +Pokrenite `opencode models` da vidite listu dostupnih modela. +::: + +--- + +## Kreiranje agenata + +Možete kreirati nove agente koristeći sljedeću naredbu: + +```bash +opencode agent create +``` + +Ova interaktivna komanda će: + +1. Pitajte gdje da sačuvate agenta; globalno ili specifično za projekat. +2. Opis onoga što agent treba da uradi. +3. Generirajte odgovarajući sistemski prompt i identifikator. +4. Omogućite vam da odaberete kojim alatima agent može pristupiti. +5. Konačno, kreirajte markdown datoteku s konfiguracijom agenta. + +--- + +## Primjeri upotrebe + +Evo nekoliko uobičajenih slučajeva upotrebe različitih agenata. + +- **Build agent**: Potpuni razvojni rad sa svim omogućenim alatima +- **Plan agent**: Analiza i planiranje bez unošenja promjena +- **Review agent**: Code review sa pristupom samo za čitanje plus alati za dokumentaciju +- **Debug agent**: Fokusiran na istragu sa omogućenim bash i alatima za čitanje +- **Docs agent**: Pisanje dokumentacije sa operacijama datoteka, ali bez sistemskih naredbi + +--- + +## Primjeri + +Evo nekoliko primjera agenata koji bi vam mogli biti korisni. + +:::tip +Imate li agenta kojeg biste željeli podijeliti? [Pošalji PR](https://github.com/anomalyco/opencode). +::: + +--- + +### Agent za dokumentaciju + +```markdown title="~/.config/opencode/agents/docs-writer.md" +--- +description: Writes and maintains project documentation +mode: subagent +tools: + bash: false +--- + +You are a technical writer. Create clear, comprehensive documentation. + +Focus on: + +- Clear explanations +- Proper structure +- Code examples +- User-friendly language +``` + +--- + +### Sigurnosni revizor + +```markdown title="~/.config/opencode/agents/security-auditor.md" +--- +description: Performs security audits and identifies vulnerabilities +mode: subagent +tools: + write: false + edit: false +--- + +You are a security expert. Focus on identifying potential security issues. + +Look for: + +- Input validation vulnerabilities +- Authentication and authorization flaws +- Data exposure risks +- Dependency vulnerabilities +- Configuration security issues +``` diff --git a/packages/web/src/content/docs/bs/cli.mdx b/packages/web/src/content/docs/bs/cli.mdx new file mode 100644 index 0000000000000000000000000000000000000000..8883e6889a42368cfc63b97c3e25da940a5fb66e --- /dev/null +++ b/packages/web/src/content/docs/bs/cli.mdx @@ -0,0 +1,614 @@ +--- +title: CLI +description: OpenCode CLI opcije i naredbe. +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" + +OpenCode CLI po defaultu pokreće [TUI](/docs/tui) kada se pokrene bez ikakvih argumenata. + +```bash +opencode +``` + +Ali takođe prihvata naredbe kao što je dokumentovano na ovoj stranici. Ovo vam omogućava programsku interakciju sa OpenCode. + +```bash +opencode run "Explain how closures work in JavaScript" +``` + +--- + +### tui + +Pokrenite OpenCode terminalski korisnički interfejs. + +```bash +opencode [project] +``` + +#### Opcije + +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Nastavite posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | +| {"--prompt"} | | Prompt za upotrebu | +| {"--model"} | `-m` | Model za korištenje u obliku provider/model | +| {"--agent"} | | Agent za korištenje | +| {"--port"} | | Port na kojem treba slušati | +| {"--hostname"} | | Hostname na kojem treba slušati | + +--- + +## Naredbe + +OpenCode CLI takođe ima sljedeće naredbe. + +--- + +### agent + +Upravljajte OpenCode agentima. + +```bash +opencode agent [command] +``` + +--- + +### attach + +Priključite terminal na već pokrenut OpenCode backend server pokrenut putem `serve` ili `web` naredbi. + +```bash +opencode attach [url] +``` + +Ovo omogućava korištenje TUI-ja sa udaljenim OpenCode backend-om. Na primjer: + +```bash +# Start the backend server for web/mobile access +opencode web --port 4096 --hostname 0.0.0.0 + +# In another terminal, attach the TUI to the running backend +opencode attach http://10.20.30.40:4096 +``` + +#### Opcije + +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| {"--dir"} | | Radni direktorij za pokretanje TUI-a | +| {"--continue"} | `-c` | Nastavi posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkuj sesiju prilikom nastavka (koristite sa `--continue` ili `--session`) | +| {"--password"} | `-p` | Lozinka za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Korisničko ime za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_USERNAME` ili `opencode`) | + +--- + +#### create + +Kreirajte novog agenta s prilagođenom konfiguracijom. + +```bash +opencode agent create +``` + +Ova naredba će vas voditi kroz kreiranje novog agenta sa prilagođenim sistemskim promptom i konfiguracijom alata. + +--- + +#### list + +Navedite sve dostupne agente. + +```bash +opencode agent list +``` + +--- + +### auth + +Naredba za upravljanje vjerodajnicama i prijavom za provajdere. + +```bash +opencode auth [command] +``` + +--- + +#### login + +OpenCode pokreće lista provajdera na [Models.dev](https://models.dev), tako da možete koristiti `opencode auth login` da konfigurirate API ključeve za bilo kojeg provajdera kojeg želite koristiti. Ovo je pohranjeno u `~/.local/share/opencode/auth.json`. + +```bash +opencode auth login +``` + +Kada se OpenCode pokrene, učitava dobavljače iz datoteke vjerodajnica. I ako postoje neki ključevi definirani u vašim okruženjima ili `.env` fajl u vašem projektu. + +--- + +#### list + +Navodi sve autentifikovane dobavljače pohranjene u datoteci vjerodajnica. + +```bash +opencode auth list +``` + +Ili kratka verzija. + +```bash +opencode auth ls +``` + +--- + +#### logout + +Odjavljuje vas s provajdera tako što ga briše iz datoteke vjerodajnica. + +```bash +opencode auth logout +``` + +--- + +### github + +Upravljajte GitHub agentom za automatizaciju repozitorija. + +```bash +opencode github [command] +``` + +--- + +#### install + +Instalirajte GitHub agenta u svoj repozitorij. + +```bash +opencode github install +``` + +Ovo postavlja neophodni tok rada GitHub Actions i vodi vas kroz proces konfiguracije. [Saznajte više](/docs/github). + +--- + +#### run + +Pokrenite GitHub agent. Ovo se obično koristi u GitHub Actions. + +```bash +opencode github run +``` + +##### Opcije + +| Opcija | Opis | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | GitHub mock event za pokretanje agenta | +| {"--token"} | GitHub Personal Access Token | + +--- + +### mcp + +Upravljajte Model Context Protocol (MCP) serverima. + +```bash +opencode mcp [command] +``` + +--- + +#### add + +Dodajte MCP server svojoj konfiguraciji. + +```bash +opencode mcp add +``` + +Ova naredba će vas voditi kroz dodavanje lokalnog ili udaljenog MCP servera. + +--- + +#### list + +Navedite sve konfigurirane MCP servere i njihov status veze. + +```bash +opencode mcp list +``` + +Ili koristite kratku verziju. + +```bash +opencode mcp ls +``` + +--- + +#### auth + +Autentifikujte se sa MCP serverom koji je omogućen za OAuth. + +```bash +opencode mcp auth [name] +``` + +Ako ne navedete ime servera, od vas će biti zatraženo da izaberete neki od dostupnih servera koji podržavaju OAuth. +Također možete navesti servere koji podržavaju OAuth i njihov status autentifikacije. + +```bash +opencode mcp auth list +``` + +Ili koristite kratku verziju. + +```bash +opencode mcp auth ls +``` + +--- + +#### logout + +Uklonite OAuth vjerodajnice za MCP server. + +```bash +opencode mcp logout [name] +``` + +--- + +#### debug + +Otklanjanje grešaka (debug) OAuth veze sa MCP serverom. + +```bash +opencode mcp debug +``` + +--- + +### models + +Navedite sve dostupne modele konfiguriranih provajdera. + +```bash +opencode models [provider] +``` + +Ova naredba prikazuje sve modele dostupne kod vaših konfiguriranih provajdera u formatu `provider/model`. +Ovo je korisno za pronalaženje tačnog naziva modela za korištenje u [vašoj konfiguraciji](/docs/config/). +Opciono možete proslijediti ID provajdera za filtriranje modela po tom dobavljaču. + +```bash +opencode models anthropic +``` + +#### Opcije + +| Opcija | Opis | +| --------------------------------------- | ------------------------------------------------------------------------ | +| {"--refresh"} | Osvježite keš modela sa models.dev | +| {"--verbose"} | Koristite detaljniji izlaz modela (uključuje metapodatke poput troškova) | + +Koristite `--refresh` zastavicu da ažurirate keširanu listu modela. Ovo je korisno kada su novi modeli dodani provajderu i želite da ih vidite u OpenCode. + +```bash +opencode models --refresh +``` + +--- + +### run + +Pokrenite OpenCode u neinteraktivnom modu tako što ćete direktno proslijediti prompt. + +```bash +opencode run [message..] +``` + +Ovo je korisno za skriptiranje, automatizaciju ili kada želite brz odgovor bez pokretanja punog TUI-ja. Na primjer: + +```bash "opencode run" +opencode run Explain the use of context in Go +``` + +Također možete priključiti pokrenutu `opencode serve` instancu kako biste izbjegli vrijeme hladnog pokretanja MCP servera pri svakom pokretanju: + +```bash +# Start a headless server in one terminal +opencode serve + +# In another terminal, run commands that attach to it +opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" +``` + +#### Opcije + +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| {"--command"} | | Naredba za pokretanje, koristite poruku za argumente | +| {"--continue"} | `-c` | Nastavite posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | +| {"--share"} | | Podijelite sesiju | +| {"--model"} | `-m` | Model za korištenje u obliku provider/model | +| {"--agent"} | | Agent za korištenje | +| {"--file"} | `-f` | Fajlovi koje treba priložiti poruci | +| {"--format"} | | Format: default (formatiran) ili json (sirovi JSON događaji) | +| {"--title"} | | Naslov sesije (koristi skraćeni prompt ako nije navedena vrijednost) | +| {"--attach"} | | Priključite na pokrenuti OpenCode server (npr. http://localhost:4096) | +| {"--password"} | `-p` | Lozinka za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Korisničko ime za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_USERNAME` ili `opencode`) | +| {"--dir"} | | Direktorij za pokretanje, ili putanja na udaljenom serveru pri spajanju | +| {"--variant"} | | Varijanta modela (napor zaključivanja specifičan za provajdera) | +| {"--thinking"} | | Prikaži blokove razmišljanja | +| {"--port"} | | Port za lokalni server (zadano na nasumični port) | + +--- + +### serve + +Pokrenite OpenCode headless server za API pristup. Pogledajte [server docs](/docs/server) za kompletan HTTP interfejs. + +```bash +opencode serve +``` + +Ovo pokreće HTTP server koji pruža API pristup funkcionalnosti OpenCode-a bez TUI interfejsa. Postavite `OPENCODE_SERVER_PASSWORD` da omogućite HTTP osnovnu auth (korisničko ime je zadano na `opencode`). + +#### Opcije + +| Opcija | Opis | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem treba slušati | +| {"--mdns"} | Omogući mDNS otkrivanje | +| {"--cors"} | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | + +--- + +### session + +Upravljajte OpenCode sesijama. + +```bash +opencode session [command] +``` + +--- + +#### list + +Navedite sve OpenCode sesije. + +```bash +opencode session list +``` + +##### Opcije + +| Opcija | Kratko | Opis | +| ----------------------------------------- | ------ | -------------------------------------- | +| {"--max-count"} | `-n` | Ograničenje na N najnovijih sesija | +| {"--format"} | | Izlazni format: table ili json (table) | + +--- + +### stats + +Prikaži statistiku upotrebe tokena i troškova za vaše OpenCode sesije. + +```bash +opencode stats +``` + +#### Opcije + +| Opcija | Opis | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| {"--days"} | Prikaži statistiku za zadnjih N dana (sva vremena) | +| {"--tools"} | Broj alata za prikaz (svi) | +| {"--models"} | Prikaži raščlambu korištenja modela (skriveno prema zadanim postavkama). Proslijedite broj za prikaz top N | +| {"--project"} | Filtriraj po projektu (svi projekti, prazan niz: trenutni projekt) | + +--- + +### export + +Izvezite podatke sesije kao JSON. + +```bash +opencode export [sessionID] +``` + +Ako ne unesete ID sesije, od vas će biti zatraženo da odaberete neku od dostupnih sesija. + +--- + +### import + +Uvezite podatke sesije iz JSON datoteke ili OpenCode dijeljenog URL-a. + +```bash +opencode import +``` + +Možete uvesti iz lokalne datoteke ili OpenCode dijeljenog URL-a. + +```bash +opencode import session.json +opencode import https://opncd.ai/s/abc123 +``` + +--- + +### web + +Pokrenite OpenCode headless server sa web interfejsom. + +```bash +opencode web +``` + +Ovo pokreće HTTP server i otvara web pretraživač za pristup OpenCode-u preko web interfejsa. Postavite `OPENCODE_SERVER_PASSWORD` da omogućite HTTP osnovnu auth (korisničko ime je zadano na `opencode`). + +#### Opcije + +| Opcija | Opis | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem treba slušati | +| {"--mdns"} | Omogući mDNS otkrivanje | +| {"--cors"} | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | + +--- + +### acp + +Pokrenite ACP (Agent Client Protocol) server. + +```bash +opencode acp +``` + +Ova naredba pokreće ACP server koji komunicira preko stdin/stdout koristeći nd-JSON. + +#### Opcije + +| Opcija | Opis | +| ---------------------------------------- | --------------------------- | +| {"--cwd"} | Radni direktorij | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem slušati | + +--- + +### uninstall + +Deinstalirajte OpenCode i uklonite sve povezane datoteke. + +```bash +opencode uninstall +``` + +#### Opcije + +| Opcija | Kratko | Opis | +| ------------------------------------------- | ------ | --------------------------------------------- | +| {"--keep-config"} | `-c` | Sačuvajte konfiguracijske datoteke | +| {"--keep-data"} | `-d` | Sačuvajte podatke i snimke sesije | +| {"--dry-run"} | | Pokažite šta bi bilo uklonjeno bez uklanjanja | +| {"--force"} | `-f` | Preskoči upite za potvrdu | + +--- + +### upgrade + +Ažurira OpenCode na najnoviju verziju ili određenu verziju. + +```bash +opencode upgrade [target] +``` + +Za nadogradnju na najnoviju verziju. + +```bash +opencode upgrade +``` + +Za nadogradnju na određenu verziju. + +```bash +opencode upgrade v0.1.48 +``` + +#### Opcije + +| Opcija | Kratko | Opis | +| -------------------------------------- | ------ | ------------------------------------------------------- | +| {"--method"} | `-m` | Korišteni način instalacije; curl, npm, pnpm, bun, brew | + +--- + +## Globalne opcije + +OpenCode CLI prihvata sljedeće globalne zastavice. + +| Opcija | Kratko | Opis | +| ------------------------------------------ | ------ | ----------------------------------------- | +| {"--help"} | `-h` | Prikaži pomoć | +| {"--version"} | `-v` | Ispiši broj verzije | +| {"--print-logs"} | | Ispis logova u stderr | +| {"--log-level"} | | Nivo logovanja (DEBUG, INFO, WARN, ERROR) | + +--- + +## Varijable okruženja + +OpenCode se može konfigurirati pomoću varijabli okruženja. + +| Varijabla | Tip | Opis | +| ------------------------------------- | ------- | ------------------------------------------------------------------ | +| `OPENCODE_AUTO_SHARE` | boolean | Automatski dijeli sesije | +| `OPENCODE_GIT_BASH_PATH` | string | Putanja do Git Bash izvršne datoteke na Windows-u | +| `OPENCODE_CONFIG` | string | Putanja do konfiguracijskog fajla | +| `OPENCODE_TUI_CONFIG` | string | Putanja do TUI konfiguracijskog fajla | +| `OPENCODE_CONFIG_DIR` | string | Putanja do konfiguracijskog direktorija | +| `OPENCODE_CONFIG_CONTENT` | string | Inline json konfiguracijski sadržaj | +| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Onemogući automatske provjere ažuriranja | +| `OPENCODE_DISABLE_PRUNE` | boolean | Onemogući brisanje (pruning) starih podataka | +| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Onemogući automatsko ažuriranje naslova terminala | +| `OPENCODE_PERMISSION` | string | Inline json konfiguracija dozvola | +| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Onemogući podrazumijevane dodatke (plugins) | +| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Onemogući automatsko preuzimanje LSP servera | +| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Omogući eksperimentalne modele | +| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Onemogući automatsko sažimanje konteksta | +| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Onemogući čitanje iz `.claude` (prompt + vještine) | +| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Onemogući čitanje `~/.claude/CLAUDE.md` | +| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Onemogući učitavanje `.claude/skills` | +| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Onemogući dohvaćanje modela iz udaljenih izvora | +| `OPENCODE_FAKE_VCS` | string | Lažni VCS provajder za potrebe testiranja | +| `OPENCODE_CLIENT` | string | Identifikator klijenta (zadano na `cli`) | +| `OPENCODE_ENABLE_EXA` | boolean | Omogući Exa alate za web pretraživanje | +| `OPENCODE_SERVER_PASSWORD` | string | Omogući osnovnu autentifikaciju za `serve`/`web` | +| `OPENCODE_SERVER_USERNAME` | string | Poništi osnovno korisničko ime autentifikacije (zadano `opencode`) | +| `OPENCODE_MODELS_URL` | string | Prilagođeni URL za dohvaćanje konfiguracije modela | + +--- + +### Eksperimentalno + +Ove varijable okruženja omogućavaju eksperimentalne karakteristike koje se mogu promijeniti ili ukloniti. + +| Varijabla | Tip | Opis | +| ----------------------------------------------- | ------- | ------------------------------------------------------- | +| `OPENCODE_EXPERIMENTAL` | boolean | Omogući eksperimentalne funkcije pod zbirnom zastavicom | +| `OPENCODE_EXPERIMENTAL_ICON_DISCOVERY` | boolean | Omogući otkrivanje ikona | +| `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT` | boolean | Onemogući kopiranje pri odabiru u TUI | +| `OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` | number | Zadano vremensko ograničenje za bash naredbe u ms | +| `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | number | Maksimalni izlazni tokeni za LLM odgovore | +| `OPENCODE_EXPERIMENTAL_FILEWATCHER` | boolean | Omogući praćenje datoteka za cijeli direktorij | +| `OPENCODE_EXPERIMENTAL_OXFMT` | boolean | Omogući oxfmt formatter | +| `OPENCODE_EXPERIMENTAL_LSP_TOOL` | boolean | Omogući eksperimentalni LSP alat | +| `OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER` | boolean | Onemogući praćenje datoteka | +| `OPENCODE_EXPERIMENTAL_EXA` | boolean | Omogući eksperimentalne Exa funkcije | +| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Omogući TY LSP za python datoteke | +| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Omogući Plan mod | +| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Omogući pozadinske zadatke subagenata | +| `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Omogući eksperimentalni sistem događaja | +| `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Omogući nativnu putanju LLM zahtjeva | +| `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Omogući paralelno izvršavanje web pretrage | +| `OPENCODE_EXPERIMENTAL_SCOUT` | boolean | Omogući Scout subagenta | +| `OPENCODE_EXPERIMENTAL_WORKSPACES` | boolean | Omogući podršku za radne prostore | diff --git a/packages/web/src/content/docs/bs/commands.mdx b/packages/web/src/content/docs/bs/commands.mdx new file mode 100644 index 0000000000000000000000000000000000000000..388a4504bdefea9251f17acf6a5bf9f5807e680e --- /dev/null +++ b/packages/web/src/content/docs/bs/commands.mdx @@ -0,0 +1,316 @@ +--- +title: Komande +description: Kreirajte prilagođene komande za zadatke koji se ponavljaju. +--- + +Prilagođene komande vam omogućavaju da odredite prompt koji želite da pokrenete kada se ta naredba izvrši u TUI-ju. + +```bash frame="none" +/my-command +``` + +Prilagođene komande su dodatak ugrađenim komandama kao što su `/init`, `/undo`, `/redo`, `/share`, `/help`. [Saznajte više](/docs/tui#commands). + +--- + +## Kreiranje datoteka naredbi + +Kreirajte markdown fajlove u direktorijumu `commands/` da definišete prilagođene komande. +Kreiraj `.opencode/commands/test.md`: + +```md title=".opencode/commands/test.md" +--- +description: Run tests with coverage +agent: build +model: anthropic/claude-3-5-sonnet-20241022 +--- + +Run the full test suite with coverage report and show any failures. +Focus on the failing tests and suggest fixes. +``` + +Frontmatter definira svojstva komande. Sadržaj postaje predložak. +Koristite komandu tako što ćete upisati `/` nakon čega slijedi naziv komande. + +```bash frame="none" +"/test" +``` + +--- + +## Konfiguracija + +Možete dodati prilagođene komande kroz OpenCode konfiguraciju ili kreiranjem markdown datoteka u direktoriju `commands/`. + +--- + +### JSON + +Koristite opciju `command` u svom OpenCode [config](/docs/config): + +```json title="opencode.jsonc" {4-12} +{ + "$schema": "https://opencode.ai/config.json", + "command": { + // This becomes the name of the command + "test": { + // This is the prompt that will be sent to the LLM + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.", + // This is shown as the description in the TUI + "description": "Run tests with coverage", + "agent": "build", + "model": "anthropic/claude-3-5-sonnet-20241022" + } + } +} +``` + +Sada možete pokrenuti ovu naredbu u TUI: + +```bash frame="none" +/test +``` + +--- + +### Markdown + +Također možete definirati komande koristeći markdown datoteke. Stavite ih u: + +- Globalno: `~/.config/opencode/commands/` +- Po projektu: `.opencode/commands/` + +```markdown title="~/.config/opencode/commands/test.md" +--- +description: Run tests with coverage +agent: build +model: anthropic/claude-3-5-sonnet-20241022 +--- + +Run the full test suite with coverage report and show any failures. +Focus on the failing tests and suggest fixes. +``` + +Ime markdown datoteke postaje ime naredbe. Na primjer, `test.md` vam omogućava da pokrenete: + +```bash frame="none" +/test +``` + +--- + +## Konfiguracija upita + +Promptovi za prilagođene komande podržavaju nekoliko posebnih čuvara mjesta i sintakse. + +--- + +### Argumenti + +Proslijedite argumente naredbama koristeći čuvar mjesta `$ARGUMENTS`. + +```md title=".opencode/commands/component.md" +--- +description: Create a new component +--- + +Create a new React component named $ARGUMENTS with TypeScript support. +Include proper typing and basic structure. +``` + +Pokrenite naredbu s argumentima: + +```bash frame="none" +/component Button +``` + +I `$ARGUMENTS` će biti zamijenjen sa `Button`. +Također možete pristupiti pojedinačnim argumentima koristeći pozicione parametre: + +- `$1` - Prvi argument +- `$2` - Drugi argument +- `$3` - Treći argument +- I tako dalje... + +Na primjer: + +```md title=".opencode/commands/create-file.md" +--- +description: Create a new file with content +--- + +Create a file named $1 in the directory $2 +with the following content: $3 +``` + +Pokrenite naredbu: + +```bash frame="none" +/create-file config.json src "{ \"key\": \"value\" }" +``` + +Ovo zamjenjuje: + +- `$1` do `config.json` +- `$2` do `src` +- `$3` do `{ "key": "value" }` + +--- + +### Shell izlaz + +Koristite _!`command`_ da ubacite izlaz [bash command](/docs/tui#bash-commands) u svoj prompt. +Na primjer, da kreirate prilagođenu naredbu koja analizira pokrivenost testom: + +```md title=".opencode/commands/analyze-coverage.md" +--- +description: Analyze test coverage +--- + +Here are the current test results: +!`npm test` + +Based on these results, suggest improvements to increase coverage. +``` + +Ili da vidite nedavne promjene: + +```md title=".opencode/commands/review-changes.md" +--- +description: Review recent changes +--- + +Recent git commits: +!`git log --oneline -10` + +Review these changes and suggest any improvements. +``` + +Naredbe se pokreću u korijenskom direktoriju vašeg projekta i njihov izlaz postaje dio prompta. + +--- + +### Reference datoteka + +Uključite datoteke u svoju naredbu koristeći `@` nakon čega slijedi naziv datoteke. + +```md title=".opencode/commands/review-component.md" +--- +description: Review component +--- + +Review the component in @src/components/Button.tsx. +Check for performance issues and suggest improvements. +``` + +Sadržaj datoteke se automatski uključuje u prompt. + +--- + +## Opcije + +Pogledajmo detaljno opcije konfiguracije. + +--- + +### Šablon + +Opcija `template` definira prompt koji će biti poslan LLM-u kada se naredba izvrši. + +```json title="opencode.json" +{ + "command": { + "test": { + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes." + } + } +} +``` + +Ovo je **obavezna** opcija konfiguracije. + +--- + +### Opis + +Koristite opciju `description` da pružite kratak opis onoga što naredba radi. + +```json title="opencode.json" +{ + "command": { + "test": { + "description": "Run tests with coverage" + } + } +} +``` + +Ovo se prikazuje kao opis u TUI-u kada unesete naredbu. + +--- + +### Agent + +Koristite `agent` konfiguraciju da opciono odredite koji [agent](/docs/agents) treba da izvrši ovu naredbu. +Ako je ovo [subagent](/docs/agents/#subagents) naredba će po defaultu pokrenuti pozivanje subagenta. +Da onemogućite ovo ponašanje, postavite `subtask` na `false`. + +```json title="opencode.json" +{ + "command": { + "review": { + "agent": "plan" + } + } +} +``` + +Ovo je **opciona** opcija konfiguracije. Ako nije navedeno, podrazumevano je vaš trenutni agent. + +--- + +### Podzadatak + +Koristite `subtask` boolean da prisilite naredbu da pokrene [subagent](/docs/agents/#subagents) pozivanje. +Ovo je korisno ako želite da naredba ne zagađuje vaš primarni kontekst i da će **primorati** agenta da djeluje kao subagent, +čak i ako je `mode` postavljeno na `primary` u konfiguraciji [agent](/docs/agents). + +```json title="opencode.json" +{ + "command": { + "analyze": { + "subtask": true + } + } +} +``` + +Ovo je **opciona** opcija konfiguracije. + +--- + +### Model + +Koristite `model` konfiguraciju da nadjačate zadani model za ovu naredbu. + +```json title="opencode.json" +{ + "command": { + "analyze": { + "model": "anthropic/claude-3-5-sonnet-20241022" + } + } +} +``` + +Ovo je **opciona** opcija konfiguracije. + +--- + +## Ugrađene naredbe + +OpenCode uključuje nekoliko ugrađenih naredbi kao što su `/init`, `/undo`, `/redo`, `/share`, `/help`; [saznaj više](/docs/tui#commands). +:::note +Prilagođene komande mogu nadjačati ugrađene komande. +::: +Ako definirate prilagođenu naredbu s istim imenom, ona će nadjačati ugrađenu naredbu. diff --git a/packages/web/src/content/docs/bs/config.mdx b/packages/web/src/content/docs/bs/config.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b37683f0201c19eec9f5a3db4392920dc5277975 --- /dev/null +++ b/packages/web/src/content/docs/bs/config.mdx @@ -0,0 +1,685 @@ +--- +title: Konfiguracija +description: Korištenje OpenCode JSON konfiguracije. +--- + +Možete konfigurirati OpenCode koristeći JSON konfiguracijski fajl. + +--- + +## Format + +OpenCode podržava i **JSON** i **JSONC** (JSON sa komentarima) formate. + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-5", + "autoupdate": true, + "server": { + "port": 4096, + }, +} +``` + +--- + +## Lokacije + +Možete postaviti svoju konfiguraciju na nekoliko različitih lokacija i one imaju drugačiji redoslijed prioriteta. + +:::note +Konfiguracijski fajlovi se **spajaju**, ne zamjenjuju. +::: + +Konfiguracijski fajlovi se spajaju, ne zamjenjuju. Kombiniraju se postavke sa sljedećih konfiguracijskih lokacija. Kasnije konfiguracije poništavaju prethodne samo za konfliktne ključeve. Nekonfliktne postavke iz svih konfiguracija su sačuvane. + +Na primjer, ako vaša globalna konfiguracija postavlja `autoupdate: true`, a vaša projektna konfiguracija postavlja `model: "anthropic/claude-sonnet-4-5"`, konačna konfiguracija će uključivati ​​obje postavke. + +--- + +### Redoslijed prioriteta + +Izvori konfiguracije se učitavaju ovim redoslijedom (kasniji izvori poništavaju ranije): + +1. **Udaljena konfiguracija** (od `.well-known/opencode`) - organizacijske postavke +2. **Globalna konfiguracija** (`~/.config/opencode/opencode.json`) - korisničke preferencije +3. **Prilagođena konfiguracija** (`OPENCODE_CONFIG` env var) - prilagođena preinačenja +4. **Konfiguracija projekta** (`opencode.json` u projektu) - postavke specifične za projekat +5. **`.opencode` direktoriji** - agenti, komande, dodaci +6. **Inline konfiguracija** (`OPENCODE_CONFIG_CONTENT` env var) - runtime preinačenja + +To znači da konfiguracije projekta mogu nadjačati globalne zadane postavke, a globalne konfiguracije mogu nadjačati postavke udaljene organizacije. + +:::note +Direktoriji `.opencode` i `~/.config/opencode` koriste **imena u množini** za poddirektorije: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/` i `themes/`. Pojedinačna imena (npr. `agent/`) su također podržana za kompatibilnost unatrag. +::: + +--- + +### Udaljeno (Remote) + +Organizacije mogu pružiti zadanu konfiguraciju preko `.well-known/opencode` krajnje tačke. Ovo se automatski preuzima kada se autentifikujete kod provajdera koji to podržava. + +Prvo se učitava udaljena konfiguracija koja služi kao osnovni sloj. Svi ostali izvori konfiguracije (globalni, projektni) mogu nadjačati ove zadane postavke. + +Na primjer, ako vaša organizacija nudi MCP servere koji su po defaultu onemogućeni: + +```json title="Remote config from .well-known/opencode" +{ + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": false + } + } +} +``` + +Možete omogućiti određene servere u vašoj lokalnoj konfiguraciji: + +```json title="opencode.json" +{ + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } + } +} +``` + +--- + +### Globalno + +Postavite svoju globalnu OpenCode konfiguraciju u `~/.config/opencode/opencode.json`. Koristite globalnu konfiguraciju za korisničke preferencije kao što su provajderi, modeli i dozvole. + +Za postavke specifične za TUI, koristite `~/.config/opencode/tui.json`. + +Globalna konfiguracija poništava zadane postavke udaljene organizacije. + +--- + +### Projekt + +Dodajte `opencode.json` u korijen projekta. Konfiguracija projekta ima najveći prioritet među standardnim konfiguracijskim datotekama - ona nadjačava globalne i udaljene konfiguracije. + +Za TUI postavke specifične za projekat, dodajte `tui.json` pored njega. + +:::tip +Postavite specifičnu konfiguraciju projekta u korijen vašeg projekta. +::: + +Kada se OpenCode pokrene, traži konfiguracijsku datoteku u trenutnom direktoriju ili prelazi do najbližeg Git direktorija. + +Ovo je također sigurno provjeriti u Git i koristi istu shemu kao globalna. + +--- + +### Prilagođena konfiguracija + +Navedite prilagođenu putanju konfiguracijske datoteke koristeći varijablu okruženja `OPENCODE_CONFIG`. + +```bash +export OPENCODE_CONFIG=/path/to/my/custom-config.json +opencode run "Hello world" +``` + +Prilagođena konfiguracija se učitava između globalne i projektne konfiguracije po redoslijedu prioriteta. + +--- + +### Prilagođeni direktorij + +Navedite prilagođeni konfiguracijski direktorij koristeći `OPENCODE_CONFIG_DIR` varijablu okruženja. U ovom direktoriju će se tražiti agenti, komande, modovi i dodaci baš kao standardni `.opencode` direktorij, i trebali bi pratiti istu strukturu. + +```bash +export OPENCODE_CONFIG_DIR=/path/to/my/config-directory +opencode run "Hello world" +``` + +Prilagođeni direktorij se učitava nakon direktorija globalne konfiguracije i `.opencode`, tako da **može nadjačati** njihove postavke. + +--- + +## Šema + +Konfiguracijski fajl ima šemu koja je definirana u [**`opencode.ai/config.json`**](https://opencode.ai/config.json). + +TUI konfiguracija koristi [**`opencode.ai/tui.json`**](https://opencode.ai/tui.json). + +Vaš editor bi trebao biti u mogućnosti da validira i autodovršava na osnovu šeme. + +--- + +### TUI + +Koristite namjenski `tui.json` (ili `tui.jsonc`) fajl za postavke specifične za TUI. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "scroll_speed": 3, + "scroll_acceleration": { + "enabled": true + }, + "diff_style": "auto" +} +``` + +Koristite `OPENCODE_TUI_CONFIG` da pokažete na prilagođeni TUI konfiguracijski fajl. + +Stari `theme`, `keybinds`, i `tui` ključevi u `opencode.json` su zastarjeli i automatski će se migrirati kada je to moguće. + +[Saznajte više o korištenju TUI ovdje](/docs/tui#configure). + +--- + +### Server + +Možete konfigurirati postavke servera za naredbe `opencode serve` i `opencode web` putem opcije `server`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true, + "mdnsDomain": "myproject.local", + "cors": ["http://localhost:5173"] + } +} +``` + +Dostupne opcije: + +- `port` - Port za slušanje. +- `hostname` - Hostname za slušanje. Kada je `mdns` omogućen i nije postavljeno ime hosta, podrazumevano je `0.0.0.0`. +- `mdns` - Omogući mDNS otkrivanje servisa. Ovo omogućava drugim uređajima na mreži da otkriju vaš OpenCode server. +- `mdnsDomain` - Prilagođeno ime domene za mDNS servis. Zadano je `opencode.local`. Korisno za pokretanje više instanci na istoj mreži. +- `cors` - Dodatni origini koji omogućavaju CORS kada koristite HTTP server iz klijenta baziranog na pretraživaču. Vrijednosti moraju biti puni origin (shema + host + opcijski port), npr. `https://app.example.com`. + +[Saznajte više o serveru ovdje](/docs/server). + +--- + +### Alati + +Možete upravljati alatima koje LLM može koristiti putem opcije `tools`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "tools": { + "write": false, + "bash": false + } +} +``` + +[Saznajte više o alatima ovdje](/docs/tools). + +--- + +### Model + +Možete konfigurirati dobavljače i modele koje želite koristiti u svojoj OpenCode konfiguraciji kroz opcije `provider`, `model` i `small_model`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": {}, + "model": "anthropic/claude-sonnet-4-5", + "small_model": "anthropic/claude-haiku-4-5" +} +``` + +Opcija `small_model` konfigurira poseban model za lagane zadatke poput generiranja naslova. Podrazumevano, OpenCode pokušava da koristi jeftiniji model ako je dostupan od vašeg provajdera, inače se vraća na vaš glavni model. + +Opcije provajdera mogu uključivati ​​`timeout` i `setCacheKey`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "timeout": 600000, + "setCacheKey": true + } + } + } +} +``` + +- `timeout` - Vrijeme čekanja zahtjeva u milisekundama (podrazumevano: 300000). Postavite na `false` da onemogućite. +- `setCacheKey` - Osigurajte da je ključ keš memorije uvijek postavljen za određenog provajdera. + +Također možete konfigurirati [lokalne modele](/docs/models#local). [Saznajte više](/docs/models). + +--- + +#### Opcije specifične za provajdere + +Neki provajderi podržavaju dodatne opcije konfiguracije osim generičkih postavki `timeout` i `apiKey`. + +##### Amazon Bedrock + +Amazon Bedrock podržava konfiguraciju specifičnu za AWS: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "my-aws-profile", + "endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" + } + } + } +} +``` + +- `region` - AWS regija za Bedrock (zadano na `AWS_REGION` env var ili `us-east-1`) +- `profile` - AWS imenovani profil iz `~/.aws/credentials` (zadano na `AWS_PROFILE` env var) +- `endpoint` - URL prilagođene krajnje tačke za VPC krajnje tačke. Ovo je alias za generičku opciju `baseURL` koristeći terminologiju specifičnu za AWS. Ako su oba navedena, `endpoint` ima prednost. + +:::note +Tokeni nosioca (`AWS_BEARER_TOKEN_BEDROCK` ili `/connect`) imaju prednost nad autentifikacijom zasnovanom na profilu. Pogledajte [prednost autentifikacije](/docs/providers#authentication-precedence) za detalje. +::: + +[Saznajte više o konfiguraciji Amazon Bedrock](/docs/providers#amazon-bedrock). + +--- + +### Teme + +Postavite vašu UI temu u `tui.json`. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "tokyonight" +} +``` + +[Saznajte više ovdje](/docs/themes). + +--- + +### Agenti + +Možete konfigurirati specijalizirane agente za određene zadatke putem opcije `agent`. + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "code-reviewer": { + "description": "Reviews code for best practices and potential issues", + "model": "anthropic/claude-sonnet-4-5", + "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", + "tools": { + // Disable file modification tools for review-only agent + "write": false, + "edit": false, + }, + }, + }, +} +``` + +Također možete definirati agente koristeći markdown datoteke u `~/.config/opencode/agents/` ili `.opencode/agents/`. [Saznajte više ovdje](/docs/agents). + +--- + +### Zadani agent + +Možete postaviti zadanog agenta koristeći opciju `default_agent`. Ovo određuje koji se agent koristi kada nijedan nije eksplicitno specificiran. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "default_agent": "plan" +} +``` + +Zadani agent mora biti primarni agent (ne podagent). Ovo može biti ugrađeni agent kao što je `"build"` ili `"plan"`, ili [prilagođeni agent](/docs/agents) koji ste definirali. Ako navedeni agent ne postoji ili je podagent, OpenCode će se vratiti na `"build"` s upozorenjem. + +Ova postavka se primjenjuje na sva sučelja: TUI, CLI (`opencode run`), desktop aplikaciju i GitHub Action. + +--- + +### Dijeljenje + +Možete konfigurirati funkciju [share](/docs/share) putem opcije `share`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "manual" +} +``` + +Ovo prihvata: + +- `"manual"` - Dozvoli ručno dijeljenje putem naredbi (podrazumevano) +- `"auto"` - Automatski dijelite nove razgovore +- `"disabled"` - Onemogući dijeljenje u potpunosti + +Podrazumevano, dijeljenje je postavljeno na ručni način rada gdje trebate eksplicitno dijeliti razgovore pomoću naredbe `/share`. + +--- + +### Naredbe + +Možete konfigurirati prilagođene naredbe za ponavljanje zadataka putem opcije `command`. + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "command": { + "test": { + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.", + "description": "Run tests with coverage", + "agent": "build", + "model": "anthropic/claude-haiku-4-5", + }, + "component": { + "template": "Create a new React component named $ARGUMENTS with TypeScript support.\nInclude proper typing and basic structure.", + "description": "Create a new component", + }, + }, +} +``` + +Također možete definirati naredbe koristeći markdown fajlove u `~/.config/opencode/commands/` ili `.opencode/commands/`. [Saznajte više ovdje](/docs/commands). + +--- + +### Prečice tipki + +Prilagodite prečice tipki u `tui.json`. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keybinds": {} +} +``` + +[Saznajte više ovdje](/docs/keybinds). + +--- + +### Automatsko ažuriranje + +OpenCode će automatski preuzeti sva nova ažuriranja kada se pokrene. Ovo možete onemogućiti opcijom `autoupdate`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "autoupdate": false +} +``` + +Ako ne želite ažuriranja, ali želite biti obaviješteni kada nova verzija bude dostupna, postavite `autoupdate` na `"notify"`. +Imajte na umu da ovo funkcionira samo ako nije instalirano pomoću upravitelja paketa kao što je Homebrew. + +--- + +### Formateri + +Možete konfigurirati formatere koda putem opcije `formatter`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "disabled": true + }, + "custom-prettier": { + "command": ["npx", "prettier", "--write", "$FILE"], + "environment": { + "NODE_ENV": "development" + }, + "extensions": [".js", ".ts", ".jsx", ".tsx"] + } + } +} +``` + +[Saznajte više o formaterima](/docs/formatters) ovdje. + +--- + +### Dozvole + +Prema zadanim postavkama, OpenCode **dopušta sve operacije** bez potrebe za eksplicitnim dopuštenjem. Ovo možete promijeniti koristeći opciju `permission`. + +Na primjer, da osigurate da alati `edit` i `bash` zahtijevaju odobrenje korisnika: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "ask", + "bash": "ask" + } +} +``` + +[Saznajte više o dozvolama](/docs/permissions) ovdje. + +--- + +### Sažimanje + +Možete kontrolirati ponašanje sažimanja konteksta putem opcije `compaction`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "compaction": { + "auto": true, + "prune": false, + "reserved": 10000 + } +} +``` + +- `auto` - Automatski sažimanje sesije kada je kontekst pun (podrazumevano: `true`). +- `prune` - Uklonite stare izlaze alata da sačuvate tokene (podrazumevano: `false`). +- `reserved` - Token buffer za sažimanje. Ostavlja dovoljno prostora da se izbjegne prelijevanje tokom sažimanja + +--- + +### Promatrač (Watcher) + +Možete konfigurirati obrasce ignoriranja promatrača datoteka putem opcije `watcher`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "watcher": { + "ignore": ["node_modules/**", "dist/**", ".git/**"] + } +} +``` + +Obrasci prate glob sintaksu. Koristite ovo da isključite bučne direktorije iz pregleda datoteka. + +--- + +### MCP serveri + +Možete konfigurirati MCP servere koje želite koristiti putem opcije `mcp`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": {} +} +``` + +[Saznajte više ovdje](/docs/mcp-servers). + +--- + +### Dodaci + +[Plugins](/docs/plugins) proširuju OpenCode sa prilagođenim alatima, kukicama i integracijama. + +Postavite datoteke dodataka u `.opencode/plugins/` ili `~/.config/opencode/plugins/`. Također možete učitati dodatke iz npm-a preko opcije `plugin`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-helicone-session", "@my-org/custom-plugin"] +} +``` + +[Saznajte više ovdje](/docs/plugins). + +--- + +### Uputstva + +Možete konfigurirati upute za model koji koristite putem opcije `instructions`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md"] +} +``` + +Ovo uzima niz putanja i glob uzoraka do datoteka instrukcija. [Saznajte više o pravilima ovdje](/docs/rules). + +--- + +### Onemogućeni provajderi + +Možete onemogućiti dobavljače koji se automatski učitavaju preko opcije `disabled_providers`. Ovo je korisno kada želite spriječiti učitavanje određenih provajdera čak i ako su njihovi vjerodajnici dostupni. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "disabled_providers": ["openai", "gemini"] +} +``` + +:::note +`disabled_providers` ima prioritet nad `enabled_providers`. +::: + +Opcija `disabled_providers` prihvata niz ID-ova provajdera. Kada je provajder onemogućen: + +- Neće se učitati čak i ako su varijable okruženja postavljene. +- Neće se učitati čak i ako su API ključevi konfigurirani putem `/connect` naredbe. +- Modeli dobavljača se neće pojaviti na listi za odabir modela. + +--- + +### Omogućeni provajderi + +Možete odrediti listu dozvoljenih dobavljača putem opcije `enabled_providers`. Kada se podesi, samo navedeni provajderi će biti omogućeni, a svi ostali će biti zanemareni. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "enabled_providers": ["anthropic", "openai"] +} +``` + +Ovo je korisno kada želite da ograničite OpenCode da koristi samo određene provajdere umjesto da ih onemogućavate jednog po jednog. + +:::note +`disabled_providers` ima prioritet nad `enabled_providers`. +::: + +Ako se provajder pojavljuje i u `enabled_providers` i `disabled_providers`, `disabled_providers` ima prioritet za kompatibilnost unatrag. + +--- + +### Eksperimentalno + +Ključ `experimental` sadrži opcije koje su u aktivnom razvoju. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": {} +} +``` + +:::caution +Eksperimentalne opcije nisu stabilne. Mogu se promijeniti ili ukloniti bez prethodne najave. +::: + +--- + +## Varijable + +Možete koristiti zamjenu varijabli u vašim konfiguracijskim datotekama da biste referencirali varijable okruženja i sadržaj datoteke. + +--- + +### Varijable okruženja + +Koristite `{env:VARIABLE_NAME}` za zamjenu varijabli okruženja: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "{env:OPENCODE_MODEL}", + "provider": { + "anthropic": { + "models": {}, + "options": { + "apiKey": "{env:ANTHROPIC_API_KEY}" + } + } + } +} +``` + +Ako varijabla okruženja nije postavljena, bit će zamijenjena praznim nizom. + +--- + +### Datoteke + +Koristite `{file:path/to/file}` da zamijenite sadržaj fajla: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["./custom-instructions.md"], + "provider": { + "openai": { + "options": { + "apiKey": "{file:~/.secrets/openai-key}" + } + } + } +} +``` + +Putanja fajla mogu biti: + +- U odnosu na direktorij konfiguracijskih datoteka +- Ili apsolutne staze koje počinju sa `/` ili `~` + +Ovo je korisno za: + +- Pohranjivanje osjetljivih podataka poput API ključeva u odvojenim datotekama. +- Uključujući velike datoteke instrukcija bez zatrpavanja vaše konfiguracije. +- Dijeljenje zajedničkih isječaka konfiguracije u više konfiguracijskih datoteka. diff --git a/packages/web/src/content/docs/bs/custom-tools.mdx b/packages/web/src/content/docs/bs/custom-tools.mdx new file mode 100644 index 0000000000000000000000000000000000000000..e933c7cb12e417e9f4d41a7e16a277c7f8210134 --- /dev/null +++ b/packages/web/src/content/docs/bs/custom-tools.mdx @@ -0,0 +1,195 @@ +--- +title: Prilagođeni alati +description: Kreirajte alate koje LLM može pozvati u otvorenom kodu. +--- + +Prilagođeni alati su funkcije koje kreirate i koje LLM može pozvati tokom razgovora. Oni rade zajedno sa [ugrađenim opencode](/docs/tools) alatima kao što su `read`, `write` i `bash`. + +--- + +## Kreiranje alata + +Alati su definisani kao **TypeScript** ili **JavaScript** datoteke. Međutim, definicija alata može pozvati skripte napisane na **bilo kom jeziku** — TypeScript ili JavaScript se koriste samo za samu definiciju alata. + +--- + +### Lokacija + +Mogu se definisati: + +- Lokalno postavljanjem u `.opencode/tools/` direktorij vašeg projekta. +- Ili globalno, postavljanjem u `~/.config/opencode/tools/`. + +--- + +### Struktura + +Najlakši način za kreiranje alata je korištenje pomoćnika `tool()` koji pruža sigurnost tipa i validaciju. + +```ts title=".opencode/tools/database.ts" {1} +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Query the project database", + args: { + query: tool.schema.string().describe("SQL query to execute"), + }, + async execute(args) { + // Your database logic here + return `Executed query: ${args.query}` + }, +}) +``` + +**ime datoteke** postaje **naziv alata**. Gore navedeno je kreirano pomoću `database` alata. + +--- + +#### Više alata po datoteci + +Također možete izvesti više alata iz jedne datoteke. Svaki izvoz postaje **poseban alat** pod nazivom **`_`**: + +```ts title=".opencode/tools/math.ts" +import { tool } from "@opencode-ai/plugin" + +export const add = tool({ + description: "Add two numbers", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args) { + return args.a + args.b + }, +}) + +export const multiply = tool({ + description: "Multiply two numbers", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args) { + return args.a * args.b + }, +}) +``` + +Ovo stvara dva alata: `math_add` i `math_multiply`. + +--- + +#### Sukob imena s ugrađenim alatima + +Prilagođeni alati su prepoznati po imenu. Ako prilagođeni alat koristi isto ime kao ugrađeni alat, prilagođeni alat ima prednost. + +Na primjer, ova datoteka zamjenjuje ugrađeni `bash` alat: + +```ts title=".opencode/tools/bash.ts" +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Restricted bash wrapper", + args: { + command: tool.schema.string(), + }, + async execute(args) { + return `blocked: ${args.command}` + }, +}) +``` + +:::note +Preferirajte jedinstvena imena osim ako namjerno ne želite zamijeniti ugrađeni alat. Ako želite onemogućiti ugrađeni alat, ali ne i nadjačati ga, koristite [dozvole](/docs/permissions). +::: + +--- + +### Argumenti + +Možete koristiti `tool.schema`, što je samo [Zod](https://zod.dev), da definirate tipove argumenata. + +```ts "tool.schema" +args: { + query: tool.schema.string().describe("SQL query to execute") +} +``` + +Također možete direktno uvesti [Zod](https://zod.dev) i vratiti običan objekt: + +```ts {6} +import { z } from "zod" + +export default { + description: "Tool description", + args: { + param: z.string().describe("Parameter description"), + }, + async execute(args, context) { + // Tool implementation + return "result" + }, +} +``` + +--- + +### Kontekst + +Alati primaju kontekst o trenutnoj sesiji: + +```ts title=".opencode/tools/project.ts" {8} +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Get project information", + args: {}, + async execute(args, context) { + // Access context information + const { agent, sessionID, messageID, directory, worktree } = context + return `Agent: ${agent}, Session: ${sessionID}, Message: ${messageID}, Directory: ${directory}, Worktree: ${worktree}` + }, +}) +``` + +Koristite `context.directory` za radni direktorij sesije. +Koristite `context.worktree` za korijen git radnog stabla. + +--- + +## Primjeri + +### Pisanje alata u Python-u + +Možete pisati svoje alate na bilo kom jeziku koji želite. Evo primjera koji zbraja dva broja koristeći Python. +Prvo kreirajte alat kao Python skriptu: + +```python title=".opencode/tools/add.py" +import sys + +a = int(sys.argv[1]) +b = int(sys.argv[2]) +print(a + b) +``` + +Zatim kreirajte definiciju alata koja ga poziva: + +```ts title=".opencode/tools/python-add.ts" {10} +import { tool } from "@opencode-ai/plugin" +import path from "path" + +export default tool({ + description: "Add two numbers using Python", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args, context) { + const script = path.join(context.worktree, ".opencode/tools/add.py") + const result = await Bun.$`python3 ${script} ${args.a} ${args.b}`.text() + return result.trim() + }, +}) +``` + +Ovdje koristimo [`Bun.$`](https://bun.com/docs/runtime/shell) uslužni program za pokretanje Python skripte. diff --git a/packages/web/src/content/docs/bs/ecosystem.mdx b/packages/web/src/content/docs/bs/ecosystem.mdx new file mode 100644 index 0000000000000000000000000000000000000000..65a665ca9ce72777c3970411a28f3a820eb8a2e5 --- /dev/null +++ b/packages/web/src/content/docs/bs/ecosystem.mdx @@ -0,0 +1,78 @@ +--- +title: Ekosistem +description: Projekti i integracije izgrađeni uz OpenCode. +--- + +Kolekcija projekata zajednice izgrađenih na OpenCode. + +:::note +Želite li na ovu listu dodati svoj OpenCode projekat? Pošaljite PR. +::: + +Također možete pogledati [awesome-opencode](https://github.com/awesome-opencode/awesome-opencode) i [opencode.cafe](https://opencode.cafe), zajednicu koja spaja ekosistem i zajednicu. + +--- + +## Dodaci + +| Ime | Opis | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| [opencode-daytona](https://github.com/daytonaio/daytona/tree/main/libs/opencode-plugin) | Automatski pokrenite OpenCode sesije u izoliranim Daytona sandboxovima uz git sinhronizaciju i preglede uživo | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatski ubacite Helicone zaglavlja sesije za grupisanje zahtjeva | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Automatski ubaci TypeScript/Svelte tipove u čitanje datoteka pomoću alata za pretraživanje | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Koristite svoju ChatGPT Plus/Pro pretplatu umjesto API kredita | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Koristite svoj postojeći Gemini plan umjesto API naplate | +| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Koristite besplatne modele Antigravity umjesto API naplate | +| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Izolacija devcontainer-a s više grana s plitkim klonovima i automatski dodijeljenim portovima | +| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth dodatak, s podrškom za Google pretraživanje i robusnijim API rukovanjem | +| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimizirajte korištenje tokena smanjenjem izlaza zastarjelih alata | +| [opencode-vibeguard](https://github.com/inkdust2021/opencode-vibeguard) | Redigujte tajne/PII u rezervirana mjesta u stilu VibeGuarda prije LLM poziva; vratite lokalno | +| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Dodajte podršku za izvorno web pretraživanje za podržane provajdere sa stilom utemeljenim na Googleu | +| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Omogućuje AI agentima da pokreću pozadinske procese u PTY-u, šalju im interaktivni ulaz. | +| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Upute za neinteraktivne naredbe ljuske - sprječava visi od TTY ovisnih operacija | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Pratite upotrebu OpenCode sa Wakatime | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Očistite tabele umanjenja vrijednosti koje su izradili LLM | +| [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | Fast Apply uređivanje, WarpGrep pretraga koda i kompresija konteksta putem Morph-a | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Pozadinski agenti, unapred izgrađeni LSP/AST/MCP alati, kurirani agenti, kompatibilni sa Claude Code | +| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Obavještenja na radnoj površini i zvučna upozorenja za OpenCode sesije | +| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Obavještenja na radnoj površini i zvučna upozorenja za dozvole, završetak i događaje greške | +| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | Automatsko imenovanje Zellij sesije na bazi OpenCode konteksta | +| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Dozvolite OpenCode agentima da lijeno učitavaju upite na zahtjev uz otkrivanje vještina i ubrizgavanje | +| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Trajna memorija kroz sesije koristeći Supermemory | +| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interaktivni pregled plana s vizualnim napomenama i privatnim/offline dijeljenjem | +| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Proširite opencode /komande u moćan sistem orkestracije sa granularnom kontrolom toka | +| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Planirajte ponavljajuće poslove koristeći launchd (Mac) ili systemd (Linux) sa cron sintaksom | +| [micode](https://github.com/vtemian/micode) | Strukturirana Brainstorm → Plan → Implementacija toka rada uz kontinuitet sesije | +| [octto](https://github.com/vtemian/octto) | Interaktivno korisničko sučelje pretraživača za AI brainstorming sa obrascima za više pitanja | +| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Pozadinski agenti u stilu Claudea s asinhroniziranim delegiranjem i postojanošću konteksta | +| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Notifikacije izvornog OS-a za OpenCode – znajte kada se zadaci dovrše | +| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Uvezeni višeagentni orkestracijski pojas – 16 komponenti, jedna instalacija | +| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Git radna stabla bez trenja za OpenCode | +| [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Pratite i otklanjajte greške svojih AI agenata uz Sentry AI Monitoring | + +--- + +## Projekti + +| Ime | Opis | +| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| [kimaki](https://github.com/remorses/kimaki) | Discord bot za kontrolu OpenCode sesija, izgrađen na SDK | +| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim dodatak za upite svjestan uređivača, izgrađen na API | +| [portal](https://github.com/hosenur/portal) | Mobilni korisnički interfejs za OpenCode preko Tailscale/VPN | +| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Predložak za izgradnju OpenCode dodataka | +| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend za opencode - terminal baziran AI agent za kodiranje | +| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK dobavljač za korištenje OpenCode putem @opencode-ai/sdk | +| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App i VS Code Extension za OpenCode | +| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian dodatak koji ugrađuje OpenCode u Obsidian-ov UI | +| [OpenWork](https://github.com/different-ai/openwork) | Alternativa otvorenog koda Claudeu Coworku, pokretana pomoću OpenCode | +| [ocx](https://github.com/kdcokenny/ocx) | OpenCode menadžer ekstenzija sa prenosivim, izolovanim profilima. | +| [CodeNomad](https://github.com/NeuralNomadsAI/CodeNomad) | Desktop, Web, Mobile i Remote Client aplikacija za OpenCode | + +--- + +## Agenti + +| Ime | Opis | +| ----------------------------------------------------------------- | --------------------------------------------------------------- | +| [Agentic](https://github.com/Cluster444/agentic) | Modularni AI agenti i komande za strukturirani razvoj | +| [opencode-agents](https://github.com/darrenhinde/opencode-agents) | Konfiguracije, upiti, agenti i dodaci za poboljšane tokove rada | diff --git a/packages/web/src/content/docs/bs/enterprise.mdx b/packages/web/src/content/docs/bs/enterprise.mdx new file mode 100644 index 0000000000000000000000000000000000000000..5851498aeb50c4099649f10e283571e09ba87a6b --- /dev/null +++ b/packages/web/src/content/docs/bs/enterprise.mdx @@ -0,0 +1,165 @@ +--- +title: Za preduzeća +description: Sigurno korištenje OpenCode u vašoj organizaciji. +--- + +import config from "../../../../config.mjs" +export const email = `mailto:${config.email}` + +OpenCode Enterprise je za organizacije koje žele osigurati da njihov kod i podaci nikada ne napuštaju njihovu infrastrukturu. To omogućava centralizovana konfiguracija koja se integriše s vašim SSO-om i internim AI gateway-om. + +:::note +OpenCode ne pohranjuje nijedan vaš kod ili kontekstualne podatke. +::: + +Da započnete s OpenCode Enterprise: + +1. Uradite interni probni period sa svojim timom. +2. **
Kontaktirajte nas** da razgovaramo o cijenama i opcijama implementacije. + +--- + +## Proba + +OpenCode je otvorenog koda i ne pohranjuje vaš kod niti kontekstualne podatke, tako da vaši developeri mogu jednostavno [započeti](/docs/) i provesti probu. + +--- + +### Rukovanje podacima + +**OpenCode ne pohranjuje vaš kod ni kontekstualne podatke.** Sva obrada se odvija lokalno ili putem direktnih API poziva vašem AI provajderu. + +To znači da, sve dok koristite provajdera kojem vjerujete ili interni AI gateway, OpenCode možete koristiti sigurno. + +Jedina iznimka je opcionalna funkcija `/share`. + +--- + +#### Dijeljenje razgovora + +Ako korisnik uključi funkciju `/share`, razgovor i povezani podaci šalju se servisu koji koristimo za hosting ovih share stranica na opencode.ai. + +Podaci se trenutno serviraju kroz edge mrežu našeg CDN-a i keširaju se blizu korisnika. + +Preporučujemo da ovo onemogućite tokom probe. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled" +} +``` + +[Saznajte više o dijeljenju](/docs/share). + +--- + +### Vlasništvo koda + +**Vi posjedujete sav kod koji OpenCode proizvede.** Nema ograničenja licenciranja niti zahtjeva za vlasništvo. + +--- + +## Cijene + +Koristimo model naplate po sjedištu za OpenCode Enterprise. Ako imate vlastiti LLM gateway, ne naplaćujemo korištene tokene. Za više detalja o cijenama i opcijama implementacije, **kontaktirajte nas**. + +--- + +## Postavljanje + +Nakon što završite probni period i spremni ste koristiti OpenCode u svojoj organizaciji, možete **kontaktirati nas** da razgovaramo o cijenama i opcijama implementacije. + +--- + +### Centralna konfiguracija + +Možemo postaviti OpenCode da koristi jednu centralnu konfiguraciju za cijelu organizaciju. + +Ta centralizovana konfiguracija može se integrisati s vašim SSO provajderom i osigurava da svi korisnici pristupaju samo vašem internom AI gateway-u. + +--- + +### SSO integracija + +Kroz centralnu konfiguraciju, OpenCode se može integrisati sa SSO provajderom vaše organizacije za autentifikaciju. + +To omogućava OpenCode da dobije vjerodajnice za interni AI gateway kroz vaš postojeći sistem upravljanja identitetom. + +--- + +### Interni AI gateway + +Uz centralnu konfiguraciju, OpenCode se može podesiti da koristi samo vaš interni AI gateway. + +Također možete onemogućiti sve druge AI provajdere, čime osiguravate da svi zahtjevi prolaze kroz odobrenu infrastrukturu vaše organizacije. + +--- + +### Samostalno hostovanje + +Iako preporučujemo onemogućavanje share stranica kako biste osigurali da podaci nikada ne napuštaju vašu organizaciju, možemo vam pomoći i da ih samostalno hostujete na vlastitoj infrastrukturi. + +Ovo je trenutno na našoj mapi puta. Ako ste zainteresovani, **javite nam**. + +--- + +## Često postavljana pitanja + +
+Šta je OpenCode Enterprise? + +OpenCode Enterprise je za organizacije koje žele osigurati da njihov kod i podaci nikada ne napuštaju njihovu infrastrukturu. To omogućava centralizovana konfiguracija koja se integriše s vašim SSO-om i internim AI gateway-om. + +
+ +
+Kako započeti s OpenCode Enterprise? + +Jednostavno započnite internu probu sa svojim timom. OpenCode po defaultu ne pohranjuje vaš kod ni kontekstualne podatke, što olakšava početak. + +Zatim **kontaktirajte nas** da razgovaramo o cijenama i opcijama implementacije. + +
+ +
+Kako funkcionišu enterprise cijene? + +Nudimo enterprise cijene po sjedištu. Ako imate vlastiti LLM gateway, ne naplaćujemo korištene tokene. Za više detalja, **kontaktirajte nas** za prilagođenu ponudu prema potrebama vaše organizacije. + +
+ +
+Jesu li moji podaci sigurni uz OpenCode Enterprise? + +Da. OpenCode ne pohranjuje vaš kod niti kontekstualne podatke. Sva obrada se odvija lokalno ili putem direktnih API poziva vašem AI provajderu. Uz centralnu konfiguraciju i SSO integraciju, vaši podaci ostaju sigurni unutar infrastrukture vaše organizacije. + +
+ +
+Možemo li koristiti vlastiti privatni NPM registar? + +OpenCode podržava privatne npm registre kroz Bunovu izvornu podršku za `.npmrc` datoteku. Ako vaša organizacija koristi privatni registar, kao što je JFrog Artifactory, Nexus ili slično, osigurajte da su developeri autentifikovani prije pokretanja OpenCode. + +Da postavite autentifikaciju s privatnim registrom: + +```bash +npm login --registry=https://your-company.jfrog.io/api/npm/npm-virtual/ +``` + +Ovo kreira `~/.npmrc` s detaljima za autentifikaciju. OpenCode će to automatski prepoznati. + +:::caution +Morate biti prijavljeni na privatni registar prije pokretanja OpenCode. +::: + +Alternativno, možete ručno konfigurisati `.npmrc` datoteku: + +```bash title="~/.npmrc" +registry=https://your-company.jfrog.io/api/npm/npm-virtual/ +//your-company.jfrog.io/api/npm/npm-virtual/:_authToken=${NPM_AUTH_TOKEN} +``` + +Developeri moraju biti prijavljeni na privatni registar prije pokretanja OpenCode kako bi se paketi mogli instalirati iz vašeg enterprise registra. + +
diff --git a/packages/web/src/content/docs/bs/formatters.mdx b/packages/web/src/content/docs/bs/formatters.mdx new file mode 100644 index 0000000000000000000000000000000000000000..af0b103ef5b7151ff660e9e06232e410f38e672c --- /dev/null +++ b/packages/web/src/content/docs/bs/formatters.mdx @@ -0,0 +1,124 @@ +--- +title: Formateri +description: OpenCode koristi formatere specifične za jezik. +--- + +OpenCode automatski formatira datoteke nakon što su napisane ili uređene pomoću formatera specifičnih za jezik. Ovo osigurava da kod koji se generira prati stilove koda vašeg projekta. + +--- + +## Ugrađeni + +OpenCode dolazi sa nekoliko ugrađenih formatera za popularne jezike i okvire. Ispod je lista formatera, podržanih ekstenzija datoteka i naredbi ili opcija konfiguracije koje su mu potrebne. +| Formatter | Ekstenzije | Zahtjevi +|-------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| gofmt | .go | `gofmt` komanda dostupna | +| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` komanda dostupna | +| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml i [više](https://prettier.io/docs/en/index.html) | `prettier` zavisnost u `package.json` | +| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml i [više](https://biomejs.dev/) | `biome.json(c)` konfiguracijski fajl | +| zig | .zig, .zon | `zig` komanda dostupna | +| clang-format | .c, .cpp, .h, .hpp, .ino i [više](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` konfiguracijski fajl | +| ktlint | .kt, .kts | `ktlint` komanda dostupna | +| ruff | .py, .pyi | `ruff` komanda dostupna sa konfiguracijom | +| rustfmt | .rs | `rustfmt` komanda dostupna | +| cargofmt | .rs | `cargo fmt` komanda dostupna | +| uv | .py, .pyi | `uv` komanda dostupna || rubocop | .rb, .rake, .gemspec, .ru | `rubocop` komanda dostupna | +| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` komanda dostupna | +| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` komanda dostupna | +| air | .R | `air` komanda dostupna | +| dart | .dart | `dart` komanda dostupna | +| dfmt | .d | `dfmt` komanda dostupna | +| ocamlformat | .ml, .mli | `ocamlformat` komanda dostupna i `.ocamlformat` konfiguracioni fajl | +| terraform | .tf, .tfvars | `terraform` komanda dostupna | +| gleam | .bleam | `gleam` komanda dostupna | +| nixfmt | .nix | `nixfmt` komanda dostupna | +| shfmt | .sh, .bash | `shfmt` komanda dostupna | +| pint | .php | `laravel/pint` zavisnost u `composer.json` || oxfmt (Eksperimentalno) | .js, .jsx, .ts, .tsx | `oxfmt` zavisnost u `package.json` i [eksperimentalna env varijabla flag](/docs/cli/#experimental) | +| ormolu | .hs | `ormolu` komanda dostupna | +Dakle, ako vaš projekat ima `prettier` u vašem `package.json`, OpenCode će ga automatski koristiti. + +--- + +## Kako radi + +Kada OpenCode piše ili uređuje datoteku, on: + +1. Provjerava ekstenziju datoteke prema svim omogućenim formaterima. +2. Pokreće odgovarajuću naredbu za formatiranje na datoteci. +3. Automatski primjenjuje promjene formatiranja. + Ovaj proces se događa u pozadini, osiguravajući da se vaši stilovi koda održavaju bez ikakvih ručnih koraka. + +--- + +## Konfiguracija + +Možete prilagoditi formatere kroz `formatter` odjeljak u vašoj OpenCode konfiguraciji. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": {} +} +``` + +Svaka konfiguracija formatera podržava sljedeće: +| Svojstvo | Vrsta | Opis +|------------- | -------- | ------------------------------------------------------- | +| `disabled` | boolean | Postavite ovo na `true` da onemogućite formater | +| `command` | string[] | Naredba za pokretanje za formatiranje | +| `environment` | objekt | Varijable okruženja koje treba postaviti prilikom pokretanja formatera | +| `extensions` | string[] | Ekstenzije datoteka koje ovaj formater treba da obrađuje | +Pogledajmo neke primjere. + +--- + +### Onemogućavanje formatera + +Da onemogućite **sve** formatere globalno, postavite `formatter` na `false`: + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": false +} +``` + +Da onemogućite **specifični** formater, postavite `disabled` na `true`: + +```json title="opencode.json" {5} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "disabled": true + } + } +} +``` + +--- + +### Prilagođeni formateri + +Možete nadjačati ugrađene formatere ili dodati nove navođenjem naredbe, varijabli okruženja i ekstenzija datoteke: + +```json title="opencode.json" {4-14} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "command": ["npx", "prettier", "--write", "$FILE"], + "environment": { + "NODE_ENV": "development" + }, + "extensions": [".js", ".ts", ".jsx", ".tsx"] + }, + "custom-markdown-formatter": { + "command": ["deno", "fmt", "$FILE"], + "extensions": [".md"] + } + } +} +``` + +**`$FILE` čuvar mjesta** u naredbi će biti zamijenjen putanjom do datoteke koja se formatira. diff --git a/packages/web/src/content/docs/bs/github.mdx b/packages/web/src/content/docs/bs/github.mdx new file mode 100644 index 0000000000000000000000000000000000000000..5946b344290752962c551542d346f15dc2e60e20 --- /dev/null +++ b/packages/web/src/content/docs/bs/github.mdx @@ -0,0 +1,311 @@ +--- +title: GitHub +description: Koristite OpenCode u GitHub problemima i zahtjevima za povlačenjem. +--- + +OpenCode se integriše sa vašim GitHub tokovom rada. Spomenite `/opencode` ili `/oc` u svom komentaru i OpenCode će izvršiti zadatke unutar vašeg GitHub Actions runnera. + +--- + +## Funkcije + +- **Problemi trijaže**: Zamolite OpenCode da ispita problem i objasni vam ga. +- **Popravi i implementiraj**: Zamolite OpenCode da popravi problem ili implementira funkciju. I radit će u novoj poslovnici i dostavljati PR sa svim promjenama. +- **Secure**: OpenCode se pokreće unutar pokretača vašeg GitHub-a. + +--- + +## Instalacija + +Pokrenite sljedeću naredbu u projektu koji se nalazi u GitHub repo: + +```bash +opencode github install +``` + +Ovo će vas provesti kroz instalaciju GitHub aplikacije, kreiranje toka posla i postavljanje tajni. + +--- + +### Ručno podešavanje + +Ili ga možete postaviti ručno. + +1. **Instalirajte GitHub aplikaciju** + Idite na [**github.com/apps/opencodegent**](https://github.com/apps/opencodegent). Uvjerite se da je instaliran na ciljnom spremištu. +2. **Dodajte radni tok** + Dodajte sljedeći fajl toka posla u `.github/workflows/opencode.yml` u svoj repo. Obavezno postavite odgovarajuće `model` i potrebne API ključeve u `env`. + +```yml title=".github/workflows/opencode.yml" {24,26} + name: opencode + + on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + + jobs: + opencode: + if: | + contains(github.event.comment.body, '/oc') || + contains(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run OpenCode + uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + # share: true + # github_token: xxxx +``` + +3. **Sačuvaj API ključeve u tajne** + U **postavkama** organizacije ili projekta proširite **Tajne i varijable** na lijevoj strani i odaberite **Radnje**. I dodajte potrebne API ključeve. + +--- + +## Konfiguracija + +- `model`: Model za korištenje s OpenCode. Uzima format `provider/model`. Ovo je **obavezno**. +- `agent`: Agent za korištenje. Mora biti primarni agent. Vraća se na `default_agent` iz konfiguracije ili `"build"` ako nije pronađen. +- `share`: Da li dijeliti OpenCode sesiju. Podrazumevano je **true** za javna spremišta. +- `prompt`: Opcioni prilagođeni upit za nadjačavanje zadanog ponašanja. Koristite ovo da prilagodite kako OpenCode obrađuje zahtjeve. +- `token`: Opcionalni GitHub pristupni token za izvođenje operacija kao što su kreiranje komentara, upisivanje promjena i otvaranje zahtjeva za povlačenjem. OpenCode prema zadanim postavkama koristi token za pristup instalaciji iz aplikacije OpenCode GitHub, tako da se urezivanje, komentari i zahtjevi za povlačenjem pojavljuju kao da dolaze iz aplikacije. + Alternativno, možete koristiti GitHub Action runner [ugrađeni `GITHUB_TOKEN`](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token) bez instaliranja OpenCode GitHub aplikacije. Samo se pobrinite da date potrebna odobrenja u svom toku rada: + +```yaml +permissions: + id-token: write + contents: write + pull-requests: write + issues: write +``` + +Također možete koristiti [Personal Access Tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)(PAT) ako želite. + +--- + +## Podržani događaji + +OpenCode se može pokrenuti sljedećim GitHub događajima: +| Vrsta događaja | Pokrenuo | Detalji +|----------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `issue_comment` | Komentirajte problem ili PR | Navedite `/opencode` ili `/oc` u svom komentaru. OpenCode čita kontekst i može kreirati grane, otvarati PR-ove ili odgovarati. | +| `pull_request_review_comment` | Komentirajte određene linije koda u PR-u | Navedite `/opencode` ili `/oc` dok pregledavate kod. OpenCode prima putanju datoteke, brojeve redova i kontekst razlike. | +| `issues` | Broj otvoren ili uređen | Automatski pokrenite OpenCode kada se problemi kreiraju ili modificiraju. Zahtijeva `prompt` unos. | +| `pull_request` | PR otvoren ili ažuriran | Automatski pokrenite OpenCode kada se PR-ovi otvore, sinkroniziraju ili ponovo otvore. Korisno za automatske recenzije. | +| `schedule` | Cron baziran raspored | Pokrenite OpenCode prema rasporedu. Zahtijeva `prompt` unos. Izlaz ide u dnevnike i PR-ove (nema problema za komentarisanje). | +| `workflow_dispatch` | Ručni okidač iz GitHub korisničkog sučelja | Aktivirajte OpenCode na zahtjev preko kartice Akcije. Zahtijeva `prompt` unos. Izlaz ide u dnevnike i PR-ove. | + +### Primjer rasporeda + +Pokrenite OpenCode po rasporedu za obavljanje automatiziranih zadataka: + +```yaml title=".github/workflows/opencode-scheduled.yml" +name: Scheduled OpenCode Task + +on: + schedule: + - cron: "0 9 * * 1" # Every Monday at 9am UTC + +jobs: + opencode: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run OpenCode + uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review the codebase for any TODO comments and create a summary. + If you find issues worth addressing, open an issue to track them. +``` + +Za zakazane događaje, unos `prompt` je **potreban** jer nema komentara za izvlačenje instrukcija. Planirani tokovi posla se pokreću bez korisničkog konteksta za provjeru dozvola, tako da tok posla mora odobriti `contents: write` i `pull-requests: write` ako očekujete da će OpenCode kreirati grane ili PR-ove. + +--- + +### Primjer zahtjeva za povlačenjem + +Automatski pregledajte PR-ove kada se otvore ili ažuriraju: + +```yaml title=".github/workflows/opencode-review.yml" +name: opencode-review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + review: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + model: anthropic/claude-sonnet-4-20250514 + use_github_token: true + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements +``` + +Za `pull_request` događaje, ako nije naveden `prompt`, OpenCode podrazumevano pregledava zahtjev za povlačenjem. + +--- + +### Primjer trijaže problema + +Automatski triažirajte nove probleme. Ovaj primjer filtrira na račune starije od 30 dana radi smanjenja neželjene pošte: + +```yaml title=".github/workflows/opencode-triage.yml" +name: Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Check account age + id: check + uses: actions/github-script@v7 + with: + script: | + const user = await github.rest.users.getByUsername({ + username: context.payload.issue.user.login + }); + const created = new Date(user.data.created_at); + const days = (Date.now() - created) / (1000 * 60 * 60 * 24); + return days >= 30; + result-encoding: string + + - uses: actions/checkout@v6 + if: steps.check.outputs.result == 'true' + with: + persist-credentials: false + + - uses: anomalyco/opencode/github@latest + if: steps.check.outputs.result == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review this issue. If there's a clear fix or relevant docs: + - Provide documentation links + - Add error handling guidance for code examples + Otherwise, do not comment. +``` + +Za `issues` događaje, `prompt` unos je **potreban** jer nema komentara za izvlačenje instrukcija. + +--- + +## Prilagođeni upiti + +Zaobiđite zadani prompt da biste prilagodili ponašanje OpenCode za vaš tok posla. + +```yaml title=".github/workflows/opencode.yml" +- uses: anomalyco/opencode/github@latest + with: + model: anthropic/claude-sonnet-4-5 + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements +``` + +Ovo je korisno za provođenje specifičnih kriterija pregleda, standarda kodiranja ili fokusnih područja relevantnih za vaš projekt. + +--- + +## Primjeri + +Evo nekoliko primjera kako možete koristiti OpenCode u GitHub. + +- **Objasnite problem** + Dodajte ovaj komentar u GitHub izdanje. + +``` + /opencode explain this issue +``` + +OpenCode će pročitati cijelu temu, uključujući sve komentare, i odgovoriti s jasnim objašnjenjem. + +- **Popravi problem** + U izdanju na GitHub-u recite: + +``` + /opencode fix this +``` + +I OpenCode će kreirati novu granu, implementirati promjene i otvoriti PR sa promjenama. + +- **Pregledajte PR-ove i izvršite izmjene** + Ostavite sljedeći komentar na GitHub PR-u. + +``` + Delete the attachment from S3 when the note is removed /oc +``` + +OpenCode će implementirati traženu promjenu i posvetiti je istom PR-u. + +- **Pregledajte određene linije koda** + Ostavite komentar direktno na linije koda u PR kartici "Files". OpenCode automatski detektuje datoteku, brojeve redova i kontekst razlike kako bi pružio precizne odgovore. + +``` + [Comment on specific lines in Files tab] + /oc add error handling here +``` + +Kada komentarišete određene linije, OpenCode prima: + +- Tačan fajl se pregleda +- Specifične linije koda +- Okolni diff kontekst +- Informacije o broju linije + Ovo omogućava više ciljanih zahtjeva bez potrebe za ručno specificiranjem putanja datoteka ili brojeva linija. diff --git a/packages/web/src/content/docs/bs/gitlab.mdx b/packages/web/src/content/docs/bs/gitlab.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a819ef5fd4b9ad6908a1c162c5d4cfc6295c83af --- /dev/null +++ b/packages/web/src/content/docs/bs/gitlab.mdx @@ -0,0 +1,188 @@ +--- +title: GitLab +description: Koristite OpenCode u GitLab problemima i zahtjevima za spajanje. +--- + +OpenCode se integriše sa vašim GitLab radnim tokom kroz vaš GitLab CI/CD cevovod ili sa GitLab Duo. +U oba slučaja, OpenCode će se pokrenuti na vašim GitLab pokretačima. + +--- + +## GitLab CI + +OpenCode radi u redovnom GitLab cevovodu. Možete ga ugraditi u cjevovod kao [CI komponenta](https://docs.gitlab.com/ee/ci/components/) +Ovdje koristimo CI/CD komponentu kreiranu u zajednici za OpenCode — [nagyv/gitlab-opencode](https://gitlab.com/nagyv/gitlab-opencode). + +--- + +### Funkcije + +- **Koristite prilagođenu konfiguraciju po poslu**: Konfigurirajte OpenCode s prilagođenim konfiguracijskim direktorijem, na primjer `./config/#custom-directory` da omogućite ili onemogućite funkcionalnost po OpenCode pozivanju. +- **Minimalno podešavanje**: CI komponenta postavlja OpenCode u pozadini, samo trebate kreirati OpenCode konfiguraciju i početnu prompt. +- **Fleksibilno**: CI komponenta podržava nekoliko ulaza za prilagođavanje njenog ponašanja + +--- + +### Podešavanje + +1. Sačuvajte JSON za autentifikaciju OpenCode kao CI varijable okruženja tipa datoteke pod **Postavke** > **CI/CD** > **Varijable**. Obavezno ih označite kao "Maskirane i skrivene". +2. Dodajte sljedeće u svoju `.gitlab-ci.yml` datoteku. + +```yaml title=".gitlab-ci.yml" +include: + - component: $CI_SERVER_FQDN/nagyv/gitlab-opencode/opencode@2 + inputs: + config_dir: ${CI_PROJECT_DIR}/opencode-config + auth_json: $OPENCODE_AUTH_JSON # The variable name for your OpenCode authentication JSON + command: optional-custom-command + message: "Your prompt here" +``` + +Za više unosa i slučajeva upotrebe [pogledajte dokumente docs](https://gitlab.com/explore/catalog/nagyv/gitlab-opencode) za ovu komponentu. + +--- + +## GitLab Duo + +OpenCode se integriše sa vašim GitLab tokovom rada. +Spomenite `@opencode` u komentaru i OpenCode će izvršiti zadatke unutar vašeg GitLab CI cevovoda. + +--- + +### Funkcije + +- **Problemi trijaže**: Zamolite OpenCode da ispita problem i objasni vam ga. +- **Popravi i implementiraj**: Zamolite OpenCode da popravi problem ili implementira funkciju. + To će kreirati novu granu i pokrenuti zahtjev za spajanje s promjenama. +- **Secure**: OpenCode radi na vašim GitLab pokretačima. + +--- + +### Podešavanje + +OpenCode radi u vašem GitLab CI/CD cevovodu, evo šta će vam trebati da ga postavite: +:::tip +Pogledajte [**GitLab dokumente**](https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/) za ažurirane upute. +::: + +1. Konfigurirajte svoje GitLab okruženje +2. Postavite CI/CD +3. Nabavite API ključ dobavljača AI modela +4. Kreirajte nalog usluge +5. Konfigurirajte CI/CD varijable +6. Kreirajte konfiguracijski fajl toka, evo primjera: + +
+ + Konfiguracija toka + + ```yaml + image: node:22-slim + commands: + - echo "Installing opencode" + - npm install --global opencode-ai + - echo "Installing glab" + - export GITLAB_TOKEN=$GITLAB_TOKEN_OPENCODE + - apt-get update --quiet && apt-get install --yes curl wget gpg git && rm --recursive --force /var/lib/apt/lists/* + - curl --silent --show-error --location "https://raw.githubusercontent.com/upciti/wakemeops/main/assets/install_repository" | bash + - apt-get install --yes glab + - echo "Configuring glab" + - echo $GITLAB_HOST + - echo "Creating OpenCode auth configuration" + - mkdir --parents ~/.local/share/opencode + - | + cat > ~/.local/share/opencode/auth.json << EOF + { + "anthropic": { + "type": "api", + "key": "$ANTHROPIC_API_KEY" + } + } + EOF + - echo "Configuring git" + - git config --global user.email "opencode@gitlab.com" + - git config --global user.name "OpenCode" + - echo "Testing glab" + - glab issue list + - echo "Running OpenCode" + - | + opencode run " + You are an AI assistant helping with GitLab operations. + + Context: $AI_FLOW_CONTEXT + Task: $AI_FLOW_INPUT + Event: $AI_FLOW_EVENT + + Please execute the requested task using the available GitLab tools. + Be thorough in your analysis and provide clear explanations. + + + Please use the glab CLI to access data from GitLab. The glab CLI has already been authenticated. You can run the corresponding commands. + + If you are asked to summarize an MR or issue or asked to provide more information then please post back a note to the MR/Issue so that the user can see it. + You don't need to commit or push up changes, those will be done automatically based on the file changes you make. + + " + - git checkout --branch $CI_WORKLOAD_REF origin/$CI_WORKLOAD_REF + - echo "Checking for git changes and pushing if any exist" + - | + if ! git diff --quiet || ! git diff --cached --quiet || [ --not --zero "$(git ls-files --others --exclude-standard)" ]; then + echo "Git changes detected, adding and pushing..." + git add . + if git diff --cached --quiet; then + echo "No staged changes to commit" + else + echo "Committing changes to branch: $CI_WORKLOAD_REF" + git commit --message "Codex changes" + echo "Pushing changes up to $CI_WORKLOAD_REF" + git push https://gitlab-ci-token:$GITLAB_TOKEN@$GITLAB_HOST/gl-demo-ultimate-dev-ai-epic-17570/test-java-project.git $CI_WORKLOAD_REF + echo "Changes successfully pushed" + fi + else + echo "No git changes detected, skipping push" + fi + variables: + - ANTHROPIC_API_KEY + - GITLAB_TOKEN_OPENCODE + - GITLAB_HOST + ``` + +
+ +Možete vidjeti [GitLab CLI agenti docs](https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/) za detaljna uputstva. + +--- + +### Primjeri + +Evo nekoliko primjera kako možete koristiti OpenCode u GitLab. +:::tip +Možete konfigurirati da koristite drugu frazu okidača od `@opencode`. +::: + +- **Objasnite problem** + Dodajte ovaj komentar u izdanje GitLaba. + +``` + @opencode explain this issue +``` + +OpenCode će pročitati problem i odgovoriti jasnim objašnjenjem. + +- **Reši problem** + U izdanju GitLaba recite: + +``` + @opencode fix this +``` + +OpenCode će kreirati novu granu, implementirati promjene i otvoriti zahtjev za spajanje s promjenama. + +- **Pregledajte zahtjeve za pridruživanje** + Ostavite sljedeći komentar na zahtjev za spajanje GitLab-a. + +``` + @opencode review this merge request +``` + +OpenCode će pregledati zahtjev za spajanje i dati povratne informacije. diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx new file mode 100644 index 0000000000000000000000000000000000000000..95c902a6fac4af9a4e880987e52b13d9366b0017 --- /dev/null +++ b/packages/web/src/content/docs/bs/go.mdx @@ -0,0 +1,381 @@ +--- +title: Go +description: Povoljna pretplata za otvorene modele za programiranje. +--- + +import config from "../../../../config.mjs" +export const console = config.console +export const email = `mailto:${config.email}` + +OpenCode Go je povoljna pretplata od **$10/mjesečno** koja vam pruža pouzdan pristup popularnim otvorenim modelima za programiranje. + +Go radi kao bilo koji drugi provajder u OpenCode-u. Pretplatite se na OpenCode Go i +dobijete svoj API ključ. On je **potpuno opcionalan** i ne morate ga koristiti da +biste koristili OpenCode. + +Prvenstveno je namijenjen međunarodnim korisnicima i pruža stabilan globalni pristup. + +--- + +## Pozadina + +Otvoreni modeli su postali zaista dobri. Sada dostižu performanse bliske +vlasničkim modelima za zadatke programiranja. A pošto ih mnogi provajderi mogu nuditi +konkurentno, obično su znatno jeftiniji. + +Međutim, dobiti pouzdan pristup s niskom latencijom do njih može biti teško. Provajderi +variraju u pogledu kvaliteta i dostupnosti. + +:::tip +Testirali smo odabranu grupu modela i provajdera koji dobro rade sa OpenCode-om. +::: + +Da bismo to popravili, uradili smo nekoliko stvari: + +1. Testirali smo odabranu grupu otvorenih modela i razgovarali s njihovim timovima o tome kako da ih + najbolje pokrenemo. +2. Zatim smo sarađivali s nekoliko provajdera kako bismo bili sigurni da se oni ispravno + poslužuju. +3. Na kraju smo benchmarkovali kombinaciju modela/provajdera i osmislili + listu koju rado preporučujemo. + +OpenCode Go vam daje pristup ovim modelima za **$10/mjesečno**. + +--- + +## Kako funkcioniše + +OpenCode Go radi kao bilo koji drugi provajder u OpenCode-u. + +1. Prijavite se na **OpenCode Zen**, pretplatite se na Go i + kopirajte svoj API ključ. +2. Pokrenite komandu `/connect` u TUI-ju, odaberite `OpenCode Go` i zalijepite + svoj API ključ. +3. Pokrenite `/models` u TUI-ju da vidite listu modela dostupnih kroz Go. + +:::note +Samo jedan član po radnom prostoru (workspace) može se pretplatiti na OpenCode Go. +::: + +Trenutna lista modela uključuje: + +- **Grok 4.6** +- **GLM-5.3-Flash** +- **GLM-5.3** +- **GLM-5.2** +- **GLM-5.1** +- **GPT 5.6 Luna** +- **Kimi K3** +- **Kimi K2.7 Code** +- **Kimi K2.6** +- **LongCat-2.0** +- **MiMo-V2.5** +- **MiMo-V2.5-Pro** +- **MiniMax M3** +- **MiniMax M2.7** +- **Muse Spark 1.3 Contributor** ([ograničene regije](https://ai.developer.meta.com/legal/geographic-use-policy)) +- **Muse Spark 1.2 Contributor** ([ograničene regije](https://ai.developer.meta.com/legal/geographic-use-policy)) +- **Qwen3.8 Max** +- **Qwen3.8 Flash** +- **Qwen3.7 Max** +- **Qwen3.7 Plus** +- **Qwen3.6 Plus** +- **DeepSeek V4.1 Flash** +- **DeepSeek V4 Pro** +- **DeepSeek V4 Flash** +- **DeepSeek V4 Flash Vision Exp** +- **Hy4 preview** +- **Hy3** + +Lista modela se može mijenjati dok testiramo i dodajemo nove. + +--- + +## Gdje ga mogu koristiti? + +OpenCode Go je osmišljen za [OpenCode](https://opencode.ai) i druge agente za programiranje +koji šalju slične vrste zahtjeva. Saobraćaj se nadzire radi otkrivanja zloupotrebe koja +drugim korisnicima narušava iskustvo. + +Vaš klijent treba: + +1. Slati tipičan saobraćaj agenta za programiranje. +2. Identifikovati se vlastitim user agentom, kao što je `my-coding-agent/1.0`, a ne + generičkim nazivom SDK-a ili HTTP biblioteke. +3. Slati stabilan ID sesije u zaglavlju `x-opencode-session` za svaki razgovor kako bismo mogli optimizovati usmjeravanje i + keširanje promptova. + +### Provjereni klijenti + +Pored OpenCode-a, potvrđeno je da sljedeći klijenti ispravno rade +s OpenCode Go. Ipak, ne garantiramo da će nastaviti raditi i u budućnosti. + +| Klijent | Podrška za sesije | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Hermes** | Buildovi koji sadrže [PR #101864](https://github.com/NousResearch/hermes-agent/pull/101864) šalju zaglavlje u glavnim i pomoćnim OpenCode zahtjevima. Ispravka je spojena nakon verzije v0.21.0; samo to izdanje je ne sadrži. | +| **Claude Code** | Go prepoznaje njegovo izvorno zaglavlje sesije. Wrapper za prilagođeno zaglavlje nije potreban. | +| **Codex** | Go prepoznaje njegovo izvorno zaglavlje sesije. Neke verzije i proxy konfiguracije ga i dalje izostavljaju; sačuvajte zaglavlje sesije pri prosljeđivanju zahtjeva. | +| **ZCode** | Go prepoznaje njegovo izvorno zaglavlje sesije. Naš [zahtjev za `x-opencode-session`](https://github.com/zai-org/feedback/issues/492) i dalje je otvoren, ali slanje tog konkretnog zaglavlja više nije potrebno. | +| **Pi** | Trenutni buildovi šalju informacije o sesiji za OpenCode. Ažurirajte starije instalacije. | +| **jcode** | Ažurirajte na **v0.81.6 ili noviju**, koja uključuje [ispravku zaglavlja sesije](https://github.com/1jehuang/jcode/issues/1167). | +| **Kilo Code CLI** | Buildovi koji sadrže [PR #13752](https://github.com/Kilo-Org/kilocode/pull/13752) vraćaju OpenCode zaglavlja sesije. Ova ispravka obuhvata CLI, ali ne i VS Code ekstenziju. Pogledajte [issue #13723](https://github.com/Kilo-Org/kilocode/issues/13723). | + +### Poznati problematični klijenti + +Ovim klijentima nedostaje podrška za sesije ili je ona nepotpuna u verzijama koje smo +istražili. Povezani izvještaji prate ispravke i zaobilazna rješenja. + +| Klijent | Status i praćenje | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **DeepSeek Harness** | Informacije o sesiji stižu za neke putanje modela, ali nedostaju za druge. Prepoznajemo njegovo izvorno zaglavlje; preostaje da se ono šalje kroz sve adaptere. [Discussion #5495](https://github.com/deepseek-ai/deepseek-harness/discussions/5495). | +| **GitHub Copilot Chat** | Automatska podrška za zaglavlje sesije zatražena je u [VS Code issue #334186](https://github.com/microsoft/vscode/issues/334186). | +| **Kimi Code** | Automatska podrška za zaglavlje sesije zatražena je u [issue #3506](https://github.com/MoonshotAI/kimi-code/issues/3506). | +| **MiMo Code** | [Issue #2317](https://github.com/XiaomiMiMo/MiMo-Code/issues/2317) ima predloženu ispravku u [PR #2327](https://github.com/XiaomiMiMo/MiMo-Code/pull/2327), koji još nije spojen. | + +## Ograničenja upotrebe + +Ograničenja upotrebe definirana su kao mjesečni iznosi u dolarima. Tabela ispod prikazuje mjesečno ograničenje i troškove tokena za svaki model. + +Svaki model ima sljedeća ograničenja upotrebe: 5 sati — 20% mjesečnog ograničenja; sedmično — 50%; i mjesečno — 100%. + +Na primjer, ako model ima mjesečno ograničenje od $60, možete potrošiti do: + +- **Ograničenje od 5 sati** — $12 potrošnje +- **Sedmično ograničenje** — $30 potrošnje +- **Mjesečno ograničenje** — $60 potrošnje + +Cijene tokena navedene su za 1M tokena. + +| Model | Input | Output | Cached Read | Cached Write | Mjesečno ograničenje | +| --------------------------------------- | ------ | ------ | ----------- | ------------ | --------------------------------------------------------- | +| GLM-5.3-Flash | $0.15 | $0.50 | $0.03 | - | **$60** | +| GLM-5.3 | $1.40 | $4.40 | $0.26 | - | **$15** | +| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | **$60** | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | **$60** | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | **$15** | +| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | **$60** | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | **$60** | +| LongCat-2.0 | $0.30 | $1.20 | $0.006 | - | **$60** | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | **$60** | +| MiMo V2.5 Pro | $0.435 | $0.87 | $0.003625 | - | **$15** | +| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | **$60** | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** | +| Muse Spark 1.3 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** | +| Muse Spark 1.2 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** | +| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | **$15** | +| Qwen3.8 Flash | $0.15 | $0.47 | $0.016 | $0.20 | **$30** | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | **$30** | +| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 | **$60** | +| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 | **$60** | +| Qwen3.6 Plus (≤ 256K tokens) | $0.50 | $3.00 | $0.05 | $0.625 | **$60** | +| Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | **$60** | +| DeepSeek V4.1 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | ~~$15~~ **$60**
4x · Do 20. septembra | +| DeepSeek V4.1 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | ~~$15~~ **$60**
4x · Do 20. septembra | +| DeepSeek V4 Pro (Off-Peak) | $0.66 | $1.98 | $0.022 | - | **$15** | +| DeepSeek V4 Pro (Peak) | $1.32 | $3.96 | $0.044 | - | **$15** | +| DeepSeek V4 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$30** | +| DeepSeek V4 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$30** | +| DeepSeek V4 Flash Vision Exp (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$15** | +| DeepSeek V4 Flash Vision Exp (Peak) | $0.30 | $1.20 | $0.006 | - | **$15** | +| Hy4 preview | $0.834 | $2.501 | $0.042 | - | **$30** | +| Hy3 | $0.14 | $0.58 | $0.035 | - | **$60** | +| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** | +| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** | +| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$15** | +| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | **$15** | + +**DeepSeek V4.1 Flash / V4 Pro / V4 Flash / V4 Flash Vision Exp:** Peak sati su 01:00-04:00 i 06:00-10:00 UTC od ponedjeljka do petka; svi ostali sati, uključujući vikende, su Off-Peak. [Saznajte više](https://api-docs.deepseek.com/quick_start/pricing/). + +**DeepSeek V4 Flash Vision Exp:** Slike se pretvaraju u tokene na osnovu svojih dimenzija i naplaćuju kao ulazni tokeni zajedno s tekstualnim tokenima. [Saznajte više](https://api-docs.deepseek.com/quick_start/pricing/). + +### Procijenjeni broj zahtjeva + +Tabela ispod daje procijenjeni broj zahtjeva na osnovu tipičnih obrazaca korištenja Go-a: + +| Model | zahtjeva na 5 sati | zahtjeva sedmično | zahtjeva mjesečno | +| ------------------------------------------------------------- | ------------------------- | -------------------------- | --------------------------- | +| GLM-5.3-Flash | 6,320 | 15,790 | 31,580 | +| GLM-5.3 | 220 | 540 | 1,080 | +| GLM-5.2 | 880 | 2,150 | 4,300 | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| Kimi K3 | 110 | 250 | 490 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | +| Kimi K2.6 | 1,150 | 2,880 | 5,750 | +| LongCat-2.0 | 11,400 | 28,600 | 57,200 | +| MiMo-V2.5 | 30,100 | 75,200 | 150,400 | +| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 3,200 | 8,000 | 16,000 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| Muse Spark 1.3 Contributor | 45,300 | 113,300 | 226,600 | +| Muse Spark 1.2 Contributor | 45,300 | 113,300 | 226,600 | +| Qwen3.8 Max | 160 | 400 | 810 | +| Qwen3.8 Flash | 5,400 | 13,500 | 27,000 | +| Qwen3.7 Max | 170 | 420 | 840 | +| Qwen3.7 Plus | 4,300 | 10,800 | 21,600 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| DeepSeek V4.1 Flash
4x · Do 20. septembra | ~~6,500~~
**26,000** | ~~16,250~~
**65,000** | ~~32,500~~
**130,000** | +| DeepSeek V4 Pro | 1,050 | 2,600 | 5,200 | +| DeepSeek V4 Flash | 13,000 | 32,500 | 65,000 | +| DeepSeek V4 Flash Vision Exp | 6,500 | 16,250 | 32,500 | +| Hy4 preview | 1,350 | 3,380 | 6,770 | +| Hy3 | 4,300 | 10,750 | 21,500 | +| Grok 4.6 | 169 | 423 | 845 | +| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 | + +Procjene koriste sljedeći broj tokena po zahtjevu; stvarna potrošnja varira. + +- Grok 4.6 — 390 ulaznih, 32,500 keširanih, 120 izlaznih tokena po zahtjevu +- GLM-5.3-Flash — 1,000 ulaznih (input), 55,000 keširanih, 200 izlaznih (output) tokena po zahtjevu +- GLM-5.3/5.2/5.1 — 700 ulaznih (input), 52,000 keširanih, 150 izlaznih (output) tokena po zahtjevu +- GPT 5.6 Luna — 1,000 ulaznih, 50,000 keširanih, 220 izlaznih tokena po zahtjevu +- Kimi K3 — 1,050 ulaznih, 76,500 keširanih, 300 izlaznih tokena po zahtjevu +- Kimi K2.7/K2.6 — 870 ulaznih, 55,000 keširanih, 200 izlaznih tokena po zahtjevu +- LongCat-2.0 — 920 ulaznih, 88,900 keširanih, 200 izlaznih tokena po zahtjevu +- DeepSeek V4.1 Flash — 410 ulaznih, 71,300 keširanih, 310 izlaznih tokena po zahtjevu +- DeepSeek V4 Pro — 750 ulaznih, 82,000 keširanih, 290 izlaznih tokena po zahtjevu +- DeepSeek V4 Flash — 410 ulaznih, 71,300 keširanih, 310 izlaznih tokena po zahtjevu +- DeepSeek V4 Flash Vision Exp — 410 ulaznih, 71,300 keširanih, 310 izlaznih tokena po zahtjevu +- MiniMax M3 — 510 ulaznih, 56,000 keširanih, 190 izlaznih tokena po zahtjevu +- MiniMax M2.7 — 300 ulaznih, 55,000 keširanih, 125 izlaznih tokena po zahtjevu +- Muse Spark 1.3 Contributor — 620 ulaznih, 71,400 keširanih, 300 izlaznih tokena po zahtjevu +- Muse Spark 1.2 Contributor — 620 ulaznih, 71,400 keširanih, 300 izlaznih tokena po zahtjevu +- Qwen3.8 Max — 420 ulaznih, 66,000 keširanih, 200 izlaznih tokena po zahtjevu +- Qwen3.8 Flash — 600 ulaznih, 58,000 keširanih, 200 izlaznih tokena po zahtjevu +- Qwen3.7 Max — 420 ulaznih, 66,000 keširanih, 200 izlaznih tokena po zahtjevu +- Qwen3.7 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu +- Qwen3.6 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu +- Hy4 preview — 830 ulaznih, 71,500 keširanih, 295 izlaznih tokena po zahtjevu +- Hy3 — 830 ulaznih, 71,500 keširanih, 295 izlaznih tokena po zahtjevu +- MiMo-V2.5 — 830 ulaznih, 71,500 keširanih, 295 izlaznih tokena po zahtjevu +- MiMo-V2.5-Pro — 790 ulaznih, 86,000 keširanih, 305 izlaznih tokena po zahtjevu + +Svoju trenutnu potrošnju možete pratiti u **konzoli**. + +:::tip +Ako dostignete ograničenje upotrebe, možete nastaviti koristiti besplatne modele. +::: + +Ograničenja upotrebe mogu se promijeniti kako budemo učili iz rane upotrebe i povratnih informacija. + +--- + +### Upotreba preko ograničenja + +Ako također imate kredite na svom Zen balansu, možete omogućiti **Use balance** +opciju u konzoli. Kada je omogućeno, Go će preći na vaš Zen balans +nakon što dostignete ograničenja upotrebe umjesto blokiranja zahtjeva. + +--- + +### Zašto neki modeli imaju manju uključenu potrošnju + +Uz Go plaćate $10 mjesečno, a uključena mjesečna potrošnja razlikuje se po modelu. + +Za većinu modela to postižemo količinskim popustima i rezervisanim GPU kapacitetom. Tu uštedu zatim prenosimo na vas kroz veću mjesečnu potrošnju. + +Za neke modele još nismo imali priliku dogovoriti popust ili ih hostovati po nižoj cijeni, bilo zato što su modeli novi ili zato što su njihove javno objavljene cijene već snižene. + +Za ove modele i dalje dobijate malo više nego da direktno plaćate provajderima modela; zato je njihova uključena mjesečna potrošnja manja. + +--- + +## Endpointi + +Također možete pristupiti Go modelima putem sljedećih API endpointa. + +| Model | Model ID | Endpoint | AI SDK Paket | +| ---------------------------- | ---------------------------- | ------------------------------------------------ | --------------------------- | +| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| GLM-5.3-Flash | glm-5.3-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.3 | glm-5.3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.2 | glm-5.2 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.1 | glm-5.1 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| LongCat-2.0 | longcat-2.0 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4.1 Flash | deepseek-v4.1-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Vision Exp | deepseek-v4-flash-vision-exp | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Muse Spark 1.3 Contributor | muse-spark-1.3-contributor | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.2 Contributor | muse-spark-1.2-contributor | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| Qwen3.8 Max | qwen3.8-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.8 Flash | qwen3.8-flash | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy4 preview | hy4-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | + +[Model id](/docs/config/#models) u vašoj OpenCode konfiguraciji +koristi format `opencode-go/`. Na primjer, za Kimi K3, koristili biste +`opencode-go/kimi-k3` u svojoj konfiguraciji. + +--- + +### Modeli + +Pun spisak dostupnih modela i njihovih metapodataka možete preuzeti na: + +``` +https://opencode.ai/zen/go/v1/models +``` + +--- + +## Privatnost + +| Model | Treniranje modela | Zadržavanje podataka | +| ---------------------------- | ----------------- | -------------------- | +| Grok 4.6 | Ne koristi se | 30 dana | +| GPT 5.6 Luna | Ne koristi se | 30 dana | +| GLM-5.3-Flash | Ne koristi se | 0 dana | +| GLM-5.3 | Ne koristi se | 0 dana | +| GLM-5.2 | Ne koristi se | 0 dana | +| GLM-5.1 | Ne koristi se | 0 dana | +| Kimi K3 | Ne koristi se | 0 dana | +| Kimi K2.7 Code | Ne koristi se | 0 dana | +| Kimi K2.6 | Ne koristi se | 0 dana | +| LongCat-2.0 | Ne koristi se | 0 dana | +| MiMo-V2.5-Pro | Ne koristi se | 0 dana | +| MiMo-V2.5 | Ne koristi se | 0 dana | +| Qwen3.8 Max | Ne koristi se | 0 dana | +| Qwen3.8 Flash | Ne koristi se | 0 dana | +| Qwen3.7 Max | Ne koristi se | 0 dana | +| Qwen3.7 Plus | Ne koristi se | 0 dana | +| Qwen3.6 Plus | Ne koristi se | 0 dana | +| MiniMax M3 | Ne koristi se | 0 dana | +| MiniMax M2.7 | Ne koristi se | 0 dana | +| Muse Spark 1.3 Contributor | Da | Nije ZDR | +| Muse Spark 1.2 Contributor | Da | Nije ZDR | +| DeepSeek V4.1 Flash | Ne koristi se | 0 dana | +| DeepSeek V4 Pro | Ne koristi se | 0 dana | +| DeepSeek V4 Flash | Ne koristi se | 0 dana | +| DeepSeek V4 Flash Vision Exp | Ne koristi se | 0 dana | +| Hy4 preview | Ne koristi se | 0 dana | +| Hy3 | Ne koristi se | 0 dana | + +- **Grok 4.6:** ZDR onemogućava važne API funkcije koje zavise od pohranjenih podataka, uključujući Responses API s očuvanjem stanja, Files and Collections i Batch API. [Saznajte više](https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr). +- **GPT 5.6 Luna:** Zapisi o nadzoru zloupotrebe generišu se za svako korištenje API funkcija i čuvaju do 30 dana. [Saznajte više](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring). +- **Muse Spark 1.3 Contributor:** Znatno snižene cijene tokena u zamjenu za dopuštenje da se vaši promptovi i odgovori modela koriste za treniranje budućih Meta modela. Dostupnost je ograničena na regije dopuštene [Pravilima geografskog korištenja](https://ai.developer.meta.com/legal/geographic-use-policy) kompanije Meta. [Saznajte više](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier). +- **Muse Spark 1.2 Contributor:** Znatno snižene cijene tokena u zamjenu za dopuštenje da se vaši promptovi i odgovori modela koriste za treniranje budućih Meta modela. Dostupnost je ograničena na regije dopuštene [Pravilima geografskog korištenja](https://ai.developer.meta.com/legal/geographic-use-policy) kompanije Meta. [Saznajte više](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier). +- **DeepSeek:** ZDR sporazum obnavlja se mjesečno. Trenutni sporazum važi do 30. septembra 2026. + +--- + +## Ciljevi + +Napravili smo OpenCode Go da bismo: + +1. Učinili AI programiranje **dostupnim** većem broju ljudi putem povoljne pretplate. +2. Pružili **pouzdan** pristup najboljim otvorenim modelima za programiranje. +3. Odabrali modele koji su **testirani i benchmarkovani** za upotrebu od strane agenata za programiranje. +4. Osigurali da **nema zaključavanja (no lock-in)**, omogućavajući vam da uz OpenCode koristite i bilo kojeg drugog provajdera. diff --git a/packages/web/src/content/docs/bs/ide.mdx b/packages/web/src/content/docs/bs/ide.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a455b91ee266e54b0e0c995441c3b4b7517f4945 --- /dev/null +++ b/packages/web/src/content/docs/bs/ide.mdx @@ -0,0 +1,47 @@ +--- +title: IDE +description: Ekstenzija OpenCode za VS Code, Cursor i druge IDE +--- + +OpenCode se integriše sa VS kodom, Cursor-om ili bilo kojim IDE-om koji podržava terminal. Samo pokrenite `opencode` u terminalu da započnete. + +--- + +## Korištenje + +- **Brzo pokretanje**: Koristite `Cmd+Esc` (Mac) ili `Ctrl+Esc` (Windows/Linux) da otvorite OpenCode u prikazu podijeljenog terminala ili fokusirajte postojeću terminalsku sesiju ako je već pokrenuta. +- **Nova sesija**: Koristite `Cmd+Shift+Esc` (Mac) ili `Ctrl+Shift+Esc` (Windows/Linux) da započnete novu OpenCode terminalsku sesiju, čak i ako je ona već otvorena. Takođe možete kliknuti na dugme OpenCode u korisničkom sučelju. +- **Svijest o kontekstu**: Automatski dijelite svoj trenutni odabir ili karticu s OpenCode. +- **Prečice za referencu datoteka**: Koristite `Cmd+Option+K` (Mac) ili `Alt+Ctrl+K` (Linux/Windows) za umetanje referenci datoteka. Na primjer, `@File#L37-42`. + +--- + +## Instalacija + +Da biste instalirali OpenCode na VS Code i popularne viljuške kao što su Cursor, Windsurf, VSCodium: + +1. Otvorite VS Code +2. Otvorite integrirani terminal +3. Pokrenite `opencode` - ekstenzija se automatski instalira + Ako s druge strane želite da koristite svoj vlastiti IDE kada pokrenete `/editor` ili `/export` iz TUI-ja, morat ćete postaviti `export EDITOR="code --wait"`. [Saznajte više](/docs/tui/#editor-setup). + +--- + +### Ručna instalacija + +Potražite **OpenCode** na Extension Marketplaceu i kliknite na **Instaliraj**. + +--- + +### Rješavanje problema + +Ako se ekstenzija ne uspije automatski instalirati: + +- Uvjerite se da koristite `opencode` u integriranom terminalu. +- Potvrdite da je CLI za vaš IDE instaliran: + - Za VS kod: `code` naredbu + - Za Cursor: `cursor` naredba + - Za Windsurf: `windsurf` komanda + - Za VSCodium: `codium` komanda + - Ako ne, pokrenite `Cmd+Shift+P` (Mac) ili `Ctrl+Shift+P` (Windows/Linux) i potražite "Shell Command: Install 'code' command in PATH" (ili ekvivalent za vaš IDE) +- Osigurajte da VS Code ima dozvolu za instaliranje ekstenzija diff --git a/packages/web/src/content/docs/bs/index.mdx b/packages/web/src/content/docs/bs/index.mdx new file mode 100644 index 0000000000000000000000000000000000000000..4af699485aa0a6a7c2fbc1abecdeafc821ea576f --- /dev/null +++ b/packages/web/src/content/docs/bs/index.mdx @@ -0,0 +1,323 @@ +--- +title: Uvod +description: Započnite sa OpenCode. +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" +import config from "../../../../config.mjs" +export const console = config.console + +[**OpenCode**](/) je AI agent za kodiranje otvorenog koda. Dostupan je kao interfejs baziran na terminalu, desktop aplikacija ili IDE ekstenzija. +![OpenCode TUI sa opencode temom](../../../assets/lander/screenshot.png) +Hajde da počnemo. + +--- + +#### Preduvjeti + +Da biste koristili OpenCode u svom terminalu, trebat će vam: + +1. Moderan emulator terminala kao što su: + - [WezTerm](https://wezterm.org), više platformi + - [Alacritty](https://alacritty.org), više platformi + - [Ghostty](https://ghostty.org), Linux i macOS + - [Kitty](https://sw.kovidgoyal.net/kitty/), Linux i macOS +2. API ključevi za LLM provajdere koje želite koristiti. + +--- + +## Instalacija + +Najlakši način za instaliranje OpenCode je putem instalacijske skripte. + +```bash +curl -fsSL https://opencode.ai/install | bash +``` + +Također ga možete instalirati pomoću sljedećih naredbi: + +- **Korištenje Node.js** + + + + + ```bash + npm install -g opencode-ai + ``` + + + + + ```bash + bun install -g opencode-ai + ``` + + + + + ```bash + pnpm install -g opencode-ai + ``` + + + + + ```bash + yarn global add opencode-ai + ``` + + + + + +- **Korištenje Homebrew-a na macOS-u i Linux-u** + +```bash + brew install anomalyco/tap/opencode +``` + +> Preporučujemo korištenje OpenCode tap za najnovija izdanja. Službenu formulu `brew install opencode` održava Homebrew tim i ažurira se rjeđe. + +- **Korištenje Parua na Arch Linuxu** + + ```bash + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) + ``` + +#### Windows + +:::tip[Preporučeno: Koristite WSL] +Za najbolje iskustvo na Windows-u preporučujemo korištenje [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). Pruža bolje performanse i potpunu kompatibilnost sa OpenCode funkcijama. +::: + +- **Korištenje Chocolatey-a** + +```bash + choco install opencode +``` + +- **Korištenje Scoop-a** + +```bash + scoop install opencode +``` + +- **Korištenje NPM-a** + +```bash + npm install -g opencode-ai +``` + +- **Korištenje Mise** + +```bash + mise use -g github:anomalyco/opencode +``` + +- **Korištenje Dockera** + +```bash + docker run -it --rm ghcr.io/anomalyco/opencode +``` + +Podrška za instaliranje OpenCode na Windows koristeći Bun je trenutno u toku. +Također možete preuzeti binarnu datoteku iz [Releases](https://github.com/anomalyco/opencode/releases). + +--- + +## Konfiguracija + +Uz OpenCode možete koristiti bilo kojeg LLM provajdera tako što ćete konfigurirati njihove API ključeve. +Ako ste tek počeli koristiti LLM provajdere, preporučujemo korištenje [OpenCode Zen](/docs/zen). +To je kurirana lista modela koji su testirani i verifikovani od strane OpenCode tima. + +1. Pokrenite naredbu `/connect` u TUI-u, odaberite opencode i idite na [opencode.ai/auth](https://opencode.ai/auth). + +```txt + /connect +``` + +2. Prijavite se, dodajte svoje detalje naplate i kopirajte svoj API ključ. +3. Zalijepite svoj API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +Alternativno, možete odabrati jednog od drugih provajdera. [Saznajte više](/docs/providers#directory). + +--- + +## Inicijalizacija + +Sada kada ste konfigurisali provajdera, možete se kretati do projekta na kojem želite raditi. + +```bash +cd /path/to/project +``` + +I pokrenite OpenCode. + +```bash +opencode +``` + +Zatim inicijalizirajte OpenCode za projekat pokretanjem sljedeće naredbe. + +```bash frame="none" +/init +``` + +Ovo će omogućiti OpenCode da analizira vaš projekat i kreira `AGENTS.md` fajl u korijenu projekta. +:::tip +Trebali biste komitovati datoteku `AGENTS.md` vašeg projekta u Git. +::: +Ovo pomaže OpenCode da razumije strukturu projekta i obrasce kodiranja koji se koriste. + +--- + +## Korištenje + +Sada ste spremni da koristite OpenCode za rad na svom projektu. Slobodno pitajte bilo šta! +Ako ste novi u korištenju agenta za AI kodiranje, evo nekoliko primjera koji bi mogli pomoći. + +--- + +### Postavljanje pitanja + +Možete zamoliti OpenCode da vam objasni kodnu bazu. +:::tip +Koristite tipku `@` za nejasnu pretragu datoteka u projektu. +::: + +```txt frame="none" "@packages/functions/src/api/index.ts" +How is authentication handled in @packages/functions/src/api/index.ts +``` + +Ovo je korisno ako postoji dio kodne baze na kojem niste radili. + +--- + +### Dodavanje funkcija + +Možete zamoliti OpenCode da vašem projektu doda nove funkcije. Iako preporučujemo da ga prvo zamolite da napravi plan. + +1. **Kreirajte plan** + OpenCode ima _Plan mod_ koji onemogućuje njegovu sposobnost da pravi promjene i umjesto toga predlaže _kako_ će implementirati ovu funkciju. + Prebacite se na njega pomoću tipke **Tab**. Vidjet ćete indikator za ovo u donjem desnom uglu. + +```bash frame="none" title="Switch to Plan mode" + +``` + +Hajde sada da opišemo šta želimo da uradi. + +```txt frame="none" + When a user deletes a note, we'd like to flag it as deleted in the database. + Then create a screen that shows all the recently deleted notes. + From this screen, the user can undelete a note or permanently delete it. +``` + +Želite da date OpenCode dovoljno detalja da razumije šta želite. Pomaže da razgovarate s njim kao da razgovarate sa mlađim programerom u svom timu. +:::tip +Dajte OpenCode dosta konteksta i primjera koji će mu pomoći da razumije šta vi želite. +::: + +2. **Ponovite plan** + Kada vam da plan, možete mu dati povratne informacije ili dodati više detalja. + +```txt frame="none" + We'd like to design this new screen using a design I've used before. + [Image #1] Take a look at this image and use it as a reference. +``` + +:::tip +Prevucite i ispustite slike u terminal da biste ih dodali u prompt. +::: +OpenCode može skenirati sve slike koje mu date i dodati ih u prompt. Možete to učiniti povlačenjem i ispuštanjem slike u terminal. + +3. **Izgradite funkciju** + Kada se osjećate ugodno s planom, vratite se na _Build mode_ ponovnim pritiskom na taster **Tab**. + +```bash frame="none" + +``` + +I tražeći od njega da napravi promjene. + +```bash frame="none" + Sounds good! Go ahead and make the changes. +``` + +--- + +### Pravljenje izmjena + +Za jednostavnije promjene, možete zamoliti OpenCode da ga direktno izgradi bez potrebe da prvo pregledate plan. + +```txt frame="none" "@packages/functions/src/settings.ts" "@packages/functions/src/notes.ts" +We need to add authentication to the /settings route. Take a look at how this is +handled in the /notes route in @packages/functions/src/notes.ts and implement +the same logic in @packages/functions/src/settings.ts +``` + +Želite da budete sigurni da ste pružili dobru količinu detalja kako bi OpenCode napravio ispravne promjene. + +--- + +### Poništavanje izmjena + +Recimo da tražite od OpenCode da izvrši neke promjene. + +```txt frame="none" "@packages/functions/src/api/index.ts" +Can you refactor the function in @packages/functions/src/api/index.ts? +``` + +Ali shvatate da to nije ono što ste željeli. Možete **poništiti** promjene koristeći naredbu `/undo`. + +```bash frame="none" +/undo +``` + +OpenCode će sada poništiti promjene koje ste napravili i ponovo prikazati vašu originalnu poruku. + +```txt frame="none" "@packages/functions/src/api/index.ts" +Can you refactor the function in @packages/functions/src/api/index.ts? +``` + +Odavde možete podesiti prompt i zamoliti OpenCode da pokuša ponovo. +:::tip +Možete pokrenuti `/undo` više puta da poništite više promjena. +::: +Ili **možete ponoviti** promjene koristeći naredbu `/redo`. + +```bash frame="none" +/redo +``` + +--- + +## Dijeljenje + +Razgovore koje imate sa OpenCode možete [dijeliti sa vašim timom](/docs/share). + +```bash frame="none" +/share +``` + +Ovo će kreirati vezu do trenutnog razgovora i kopirati je u međuspremnik. +:::note +Razgovori se ne dijele prema zadanim postavkama. +::: +Evo [primjer razgovora](https://opencode.ai/s/4XP1fce5) sa OpenCode. + +--- + +## Prilagođavanje + +I to je to! Sada ste profesionalac u korištenju OpenCode. +Da biste to učinili svojim, preporučujemo [odabir teme](/docs/themes), [prilagođavanje povezivanja tipki](/docs/keybinds), [konfiguriranje formatera koda](/docs/formatters), [kreiranje prilagođenih komandi](/docs/commands), ili igranje sa [OpenCode config](/docs/config). diff --git a/packages/web/src/content/docs/bs/keybinds.mdx b/packages/web/src/content/docs/bs/keybinds.mdx new file mode 100644 index 0000000000000000000000000000000000000000..31fed590058f7dbdc208102b3dc2e7dc71e493f1 --- /dev/null +++ b/packages/web/src/content/docs/bs/keybinds.mdx @@ -0,0 +1,194 @@ +--- +title: Prečice tipki +description: Prilagodite svoje veze tipki. +--- + +OpenCode ima listu veza tipki koje možete prilagoditi putem `tui.json`. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keybinds": { + "leader": "ctrl+x", + "app_exit": "ctrl+c,ctrl+d,q", + "editor_open": "e", + "theme_list": "t", + "sidebar_toggle": "b", + "scrollbar_toggle": "none", + "username_toggle": "none", + "status_view": "s", + "tool_details": "none", + "session_export": "x", + "session_new": "n", + "session_list": "l", + "session_timeline": "g", + "session_fork": "none", + "session_rename": "none", + "session_share": "none", + "session_unshare": "none", + "session_interrupt": "escape", + "session_compact": "c", + "session_child_first": "down", + "session_child_cycle": "right", + "session_child_cycle_reverse": "left", + "session_parent": "up", + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", + "messages_half_page_up": "ctrl+alt+u", + "messages_half_page_down": "ctrl+alt+d", + "messages_first": "ctrl+g,home", + "messages_last": "ctrl+alt+g,end", + "messages_next": "none", + "messages_previous": "none", + "messages_copy": "y", + "messages_undo": "u", + "messages_redo": "r", + "messages_last_user": "none", + "messages_toggle_conceal": "h", + "model_list": "m", + "model_cycle_recent": "f2", + "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", + "variant_cycle": "ctrl+t", + "variant_list": "none", + "command_list": "ctrl+p", + "agent_list": "a", + "agent_cycle": "tab", + "agent_cycle_reverse": "shift+tab", + "input_clear": "ctrl+c", + "input_paste": "ctrl+v", + "input_submit": "return", + "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input_move_left": "left,ctrl+b", + "input_move_right": "right,ctrl+f", + "input_move_up": "up", + "input_move_down": "down", + "input_select_left": "shift+left", + "input_select_right": "shift+right", + "input_select_up": "shift+up", + "input_select_down": "shift+down", + "input_line_home": "ctrl+a", + "input_line_end": "ctrl+e", + "input_select_line_home": "ctrl+shift+a", + "input_select_line_end": "ctrl+shift+e", + "input_visual_line_home": "alt+a", + "input_visual_line_end": "alt+e", + "input_select_visual_line_home": "alt+shift+a", + "input_select_visual_line_end": "alt+shift+e", + "input_buffer_home": "home", + "input_buffer_end": "end", + "input_select_buffer_home": "shift+home", + "input_select_buffer_end": "shift+end", + "input_delete_line": "ctrl+shift+d", + "input_delete_to_line_end": "ctrl+k", + "input_delete_to_line_start": "ctrl+u", + "input_backspace": "backspace,shift+backspace", + "input_delete": "ctrl+d,delete,shift+delete", + "input_undo": "ctrl+-,super+z", + "input_redo": "ctrl+.,super+shift+z", + "input_word_forward": "alt+f,alt+right,ctrl+right", + "input_word_backward": "alt+b,alt+left,ctrl+left", + "input_select_word_forward": "alt+shift+f,alt+shift+right", + "input_select_word_backward": "alt+shift+b,alt+shift+left", + "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", + "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", + "history_previous": "up", + "history_next": "down", + "terminal_suspend": "ctrl+z", + "terminal_title_toggle": "none", + "tips_toggle": "h", + "display_thinking": "none" + } +} +``` + +--- + +## Leader tipka + +OpenCode koristi `leader` (vodeću) tipku za većinu povezivanja tipki. Ovo izbjegava sukobe u vašem terminalu. + +Prema zadanim postavkama, `ctrl+x` je vodeća tipka i većina radnji zahtijeva da prvo pritisnete vodeću tipku, a zatim i prečicu. Na primjer, da biste započeli novu sesiju, prvo pritisnite `ctrl+x`, a zatim pritisnite `n`. + +Ne morate koristiti vodeću tipku za svoje veze tipki, ali preporučujemo da to učinite. + +--- + +## Onemogućavanje prečica tipki + +Možete onemogućiti spajanje tipki dodavanjem ključa u `tui.json` s vrijednošću "none". + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keybinds": { + "session_compact": "none" + } +} +``` + +--- + +## Prečice za radnu površinu + +Unos prompta aplikacije OpenCode za desktop podržava uobičajene prečice u stilu Readline/Emacs za uređivanje teksta. One su ugrađene i trenutno se ne mogu konfigurirati putem `opencode.json`. + +| Prečica | Akcija | +| -------- | ------------------------------------------------ | +| `ctrl+a` | Prelazak na početak trenutnog reda | +| `ctrl+e` | Prelazak na kraj trenutnog reda | +| `ctrl+b` | Pomjeri kursor za jedan znak unazad | +| `ctrl+f` | Pomicanje kursora naprijed za jedan znak | +| `alt+b` | Pomjeri kursor za jednu riječ unazad | +| `alt+f` | Pomjeri kursor za jednu riječ unaprijed | +| `ctrl+d` | Izbriši znak ispod kursora | +| `ctrl+k` | Kill do kraja reda | +| `ctrl+u` | Kill do početka reda | +| `ctrl+w` | Kill prethodnu riječ | +| `alt+d` | Kill sljedeću riječ | +| `ctrl+t` | Transponirajte znakove | +| `ctrl+g` | Otkaži iskakanje / poništi odgovor na pokretanje | + +--- + +## Shift+Enter + +Neki terminali ne šalju modifikatorske tipke sa Enter prema zadanim postavkama. Možda ćete trebati konfigurirati svoj terminal da pošalje `Shift+Enter` kao escape sekvencu. + +### Windows Terminal + +Otvorite svoj `settings.json` na: + +``` +%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json +``` + +Dodajte ovo u niz korijenskog nivoa `actions`: + +```json +"actions": [ + { + "command": { + "action": "sendInput", + "input": "\u001b[13;2u" + }, + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +Dodajte ovo u niz korijenskog nivoa `keybindings`: + +```json +"keybindings": [ + { + "keys": "shift+enter", + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +Sačuvajte datoteku i ponovo pokrenite Windows Terminal ili otvorite novu karticu. diff --git a/packages/web/src/content/docs/bs/lsp.mdx b/packages/web/src/content/docs/bs/lsp.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a30d589f9a1c64628d647eab0eb7d0609fe6572a --- /dev/null +++ b/packages/web/src/content/docs/bs/lsp.mdx @@ -0,0 +1,204 @@ +--- +title: LSP serveri +description: OpenCode se integriše sa vašim LSP serverima. +--- + +OpenCode se može integrisati sa Language Server Protocol (LSP) serverima kako bi koristio dijagnostiku kao feedback za agenta. + +## Ugrađeni + +OpenCode dolazi sa nekoliko ugrađenih LSP servera za popularne jezike: + +| LSP server | Ekstenzije | Zahtjevi | +| ------------------ | ------------------------------------------------------------------- | -------------------------------------------------------- | +| astro | .astro | Automatske instalacije za Astro projekte | +| bash | .sh, .bash, .zsh, .ksh | Automatski instalira bash-language-server | +| clangd | .c, .cpp, .cc, .cxx, .c++, .h, .hpp, .hh, .hxx, .h++ | Automatske instalacije za C/C++ projekte | +| csharp | .cs | `.NET SDK` instaliran | +| clojure-lsp | .clj, .cljs, .cljc, .edn | `clojure-lsp` komanda dostupna | +| dart | .dart | `dart` komanda dostupna | +| deno | .ts, .tsx, .js, .jsx, .mjs | `deno` komanda dostupna (automatski detektuje deno.json) | +| elixir-ls | .ex, .exs | `elixir` komanda dostupna | +| eslint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue | `eslint` ovisnost u projektu | +| fsharp | .fs, .fsi, .fsx, .fsscript | `.NET SDK` instaliran | +| gleam | .gleam | `gleam` komanda dostupna | +| gopls | .go | `go` komanda dostupna | +| hls | .hs, .lhs | `haskell-language-server-wrapper` komanda dostupna | +| jdtls | .java | `Java SDK (version 21+)` instaliran | +| julials | .jl | `julia` i `LanguageServer.jl` instalirani | +| kotlin-ls | .kt, .kts | Automatske instalacije za Kotlin projekte | +| lua-ls | .lua | Automatske instalacije za Lua projekte | +| nixd | .nix | `nixd` komanda dostupna | +| ocaml-lsp | .ml, .mli | `ocamllsp` komanda dostupna | +| oxlint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue, .astro, .svelte | `oxlint` zavisnost u projektu | +| php intelephense | .php | Automatske instalacije za PHP projekte | +| prisma | .prisma | `prisma` komanda dostupna | +| pyright | .py, .pyi | `pyright` ovisnost instalirana | +| ruby-lsp (rubocop) | .rb, .rake, .gemspec, .ru | `ruby` i `gem` komande dostupne | +| rust | .rs | `rust-analyzer` komanda dostupna | +| sourcekit-lsp | .swift, .objc, .objcpp | `swift` instaliran (`xcode` na macOS-u) | +| svelte | .svelte | Automatske instalacije za Svelte projekte | +| terraform | .tf, .tfvars | Automatske instalacije iz GitHub izdanja | +| tinymist | .typ, .typc | Automatske instalacije iz GitHub izdanja | +| typescript | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts | `typescript` zavisnost u projektu | +| vue | .vue | Automatske instalacije za Vue projekte | +| yaml-ls | .yaml, .yml | Automatski instalira Red Hat yaml-language-server | +| zls | .zig, .zon | `zig` komanda dostupna | + +LSP je podrazumijevano isključen. Kada je omogućen, serveri se pokreću kada se otkrije jedna od gore navedenih ekstenzija datoteke i zahtjevi su ispunjeni. +:::note +Možete onemogućiti automatska preuzimanja LSP servera tako što ćete postaviti varijablu okruženja `OPENCODE_DISABLE_LSP_DOWNLOAD` na `true`. +::: + +--- + +## Kako radi + +Kada je LSP omogućen i opencode otvori fajl, on: + +1. Provjerava ekstenziju datoteke u odnosu na sve omogućene LSP servere. +2. Pokreće odgovarajući LSP server ako već nije pokrenut. + +--- + +## Najbolje prakse + +LSP može pomoći agentu da pronađe i popravi probleme pružanjem dijagnostike iz jezičkih servera. Ovo je korisno u nekim projektima, ali nije uvijek neto pozitivno. + +Jezički serveri mogu ispasti iz sinhronizacije, koristiti mnogo memorije, razlikovati se po verziji ili projektu i usporiti agent workflow. U mnogim projektima je bolje da agent direktno pokreće lint, typecheck ili druge dijagnostičke CLI alate, tako da se greške vraćaju u agent loop bez tih kompromisa. Dokumentujte te komande u instrukcijskim fajlovima kao što su `AGENTS.md` ili skills, kako bi agent znao šta treba pokrenuti. Uključite LSP kada vaš projekt ima koristi od dodatnog feedbacka jezičkog servera. + +--- + +## Konfiguracija + +Možete omogućiti i prilagoditi LSP servere kroz `lsp` odjeljak u vašoj opencode konfiguraciji. + +Da biste omogućili sve ugrađene LSP servere, postavite `lsp` na `true`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +Koristite objekt da zadržite ugrađene servere omogućene dok konfigurirate izmjene ili prilagođene servere. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": {} +} +``` + +Svaki LSP server podržava sljedeće: + +| Svojstvo | Vrsta | Opis | +| ---------------- | -------- | -------------------------------------------------------------------- | +| `disabled` | boolean | Postavite ovo na `true` da onemogućite LSP server | +| `command` | string[] | Naredba za pokretanje LSP servera | +| `extensions` | string[] | Ekstenzije datoteka koje ovaj LSP server treba da rukuje | +| `env` | objekt | Varijable okruženja koje treba postaviti prilikom pokretanja servera | +| `initialization` | objekt | Opcije inicijalizacije za slanje na LSP server | + +Pogledajmo neke primjere. + +--- + +### Varijable okruženja + +Koristite svojstvo `env` za postavljanje varijabli okruženja prilikom pokretanja LSP servera: + +```json title="opencode.json" {5-7} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "rust": { + "env": { + "RUST_LOG": "debug" + } + } + } +} +``` + +--- + +### Opcije inicijalizacije + +Koristite svojstvo `initialization` da prosledite opcije inicijalizacije na LSP server. Ovo su postavke specifične za server poslane tokom LSP `initialize` zahtjeva: + +```json title="opencode.json" {5-9} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "initialization": { + "preferences": { + "importModuleSpecifierPreference": "relative" + } + } + } + } +} +``` + +:::note +Opcije inicijalizacije razlikuju se od LSP servera. Provjerite dokumentaciju vašeg LSP servera za dostupne opcije. +::: + +--- + +### Onemogućavanje LSP servera + +Ako je `lsp` izostavljen, svi LSP serveri su onemogućeni. Da biste onemogućili sve LSP servere nakon što ih je druga konfiguracija omogućila, postavite `lsp` na `false`: + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": false +} +``` + +Da onemogućite **specifičan** LSP server, postavite `disabled` na `true`: + +```json title="opencode.json" {5} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "disabled": true + } + } +} +``` + +--- + +### Prilagođeni LSP serveri + +Možete dodati prilagođene LSP servere navodeći ekstenzije naredbe i datoteke: + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "custom-lsp": { + "command": ["custom-lsp-server", "--stdio"], + "extensions": [".custom"] + } + } +} +``` + +--- + +## Dodatne informacije + +### PHP Intelephense + +PHP Intelephense nudi vrhunske funkcije putem licencnog ključa. Možete dati licencni ključ postavljanjem (samo) ključa u tekstualnu datoteku na: + +- Na macOS/Linuxu: `$HOME/intelephense/license.txt` +- Na Windowsima: `%USERPROFILE%/intelephense/license.txt` + Datoteka treba da sadrži samo licencni ključ bez dodatnog sadržaja. diff --git a/packages/web/src/content/docs/bs/mcp-servers.mdx b/packages/web/src/content/docs/bs/mcp-servers.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b516bcec08fa36fdf508a44c0c72f2bd1de98662 --- /dev/null +++ b/packages/web/src/content/docs/bs/mcp-servers.mdx @@ -0,0 +1,482 @@ +--- +title: MCP serveri +description: Dodajte lokalne i udaljene MCP alate. +--- + +Možete dodati vanjske alate u OpenCode koristeći _Model Context Protocol_, ili MCP. OpenCode podržava i lokalne i udaljene servere. +Jednom dodani, MCP alati su automatski dostupni LLM-u zajedno sa ugrađenim alatima. + +--- + +#### Upozorenje + +Kada koristite MCP server, on dodaje u kontekst. Ovo se može brzo zbrojiti ako imate puno alata. Stoga preporučujemo da pazite koje MCP servere koristite. +:::tip +MCP serveri dodaju vaš kontekst, tako da želite da budete pažljivi s tim koje ćete omogućiti. +::: + +Određeni MCP serveri, poput GitHub MCP servera, mogu dodati mnogo tokena i lako premašiti limit konteksta. + +## Omogućavanje + +Možete definirati MCP servere u vašoj [OpenCode Config](https://opencode.ai/docs/config/) pod `mcp`. Dodajte svaki MCP sa jedinstvenim imenom. Možete se pozvati na taj MCP po imenu kada tražite LLM. + +```jsonc title="opencode.jsonc" {6} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "name-of-mcp-server": { + // ... + "enabled": true, + }, + "name-of-other-mcp-server": { + // ... + }, + }, +} +``` + +Također možete onemogućiti server postavljanjem `enabled` na `false`. To je korisno kada ga želite privremeno isključiti bez uklanjanja iz konfiguracije. + +### Poništavanje udaljenih zadanih postavki + +Organizacije mogu obezbijediti zadane MCP servere preko svoje krajnje tačke `.well-known/opencode`. Ovi serveri mogu biti onemogućeni prema zadanim postavkama, omogućavajući korisnicima da se odluče za one koji su im potrebni. +Da omogućite određeni server iz udaljene konfiguracije vaše organizacije, dodajte ga u svoju lokalnu konfiguraciju sa `enabled: true`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } + } +} +``` + +Vrijednosti lokalne konfiguracije nadjačavaju udaljene zadane postavke. Pogledajte [config precedence](/docs/config#precedence-order) za više detalja. + +## Lokalno + +Dodajte lokalne MCP servere koristeći `type` u `"local"` unutar MCP objekta. + +```jsonc title="opencode.jsonc" {15} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-local-mcp-server": { + "type": "local", + // Or ["bun", "x", "my-mcp-command"] + "command": ["npx", "-y", "my-mcp-command"], + "enabled": true, + "environment": { + "MY_ENV_VAR": "my_env_var_value", + }, + }, + }, +} +``` + +Naredba je način na koji se pokreće lokalni MCP server. Također možete proslijediti listu varijabli okruženja. +Na primjer, evo kako možete dodati testni [`@modelcontextprotocol/server-everything`](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) MCP server. + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "mcp_everything": { + "type": "local", + "command": ["npx", "-y", "@modelcontextprotocol/server-everything"], + }, + }, +} +``` + +I da ga koristim mogu dodati `use the mcp_everything tool` u svoje upite. + +```txt "mcp_everything" +use the mcp_everything tool to add the number 3 and 4 +``` + +--- + +#### Opcije + +Ovdje su sve opcije za konfiguriranje lokalnog MCP servera. +| Opcija | Tip | Obavezno | Opis +|------------- | ------- | -------- | ----------------------------------------------------------------------------------- | +| `type` | String | Y | Tip veze sa MCP serverom, mora biti `"local"`. | +| `command` | Niz | Y | Naredba i argumenti za pokretanje MCP servera. | +| `environment` | Objekt | | Varijable okruženja koje treba postaviti prilikom pokretanja servera. | +| `enabled` | Boolean | | Omogućite ili onemogućite MCP server pri pokretanju. | +| `timeout` | Broj | | Vremensko ograničenje u ms za dohvaćanje alata sa MCP servera. Podrazumevano je 5000 (5 sekundi). | + +--- + +## Udaljeno + +Dodajte udaljene MCP servere postavljanjem `type` na `"remote"`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-remote-mcp": { + "type": "remote", + "url": "https://my-mcp-server.com", + "enabled": true, + "headers": { + "Authorization": "Bearer MY_API_KEY" + } + } + } +} +``` + +`url` je URL udaljenog MCP servera, a kroz opciju `headers` možete proslijediti listu zaglavlja. + +#### Opcije + +| Opcija | Tip | Obavezno | Opis | +| --------- | ------- | -------- | -------------------------------------------------------------------------------------------------- | +| `type` | String | Y | Tip veze sa MCP serverom, mora biti `"remote"`. | +| `url` | String | Y | URL udaljenog MCP servera. | +| `enabled` | Boolean | | Omogućite ili onemogućite MCP server pri pokretanju. | +| `headers` | Objekt | | Zaglavlja za slanje uz zahtjev. | +| `oauth` | Objekt | | Konfiguracija OAuth provjere autentičnosti. Pogledajte odjeljak [OAuth](#oauth) ispod. | +| `timeout` | Broj | | Vremensko ograničenje u ms za preuzimanje alata sa MCP servera. Podrazumevano je 5000 (5 sekundi). | + +--- + +## OAuth + +OpenCode automatski rukuje OAuth autentifikacijom za udaljene MCP servere. Kada server zahtijeva autentifikaciju, OpenCode će: + +1. Otkrijte 401 odgovor i pokrenite OAuth tok +2. Koristite **Dynamic Client Registration (RFC 7591)** ako podržava server +3. Sigurno čuvajte tokene za buduće zahtjeve + +--- + +### Automatski + +Za većinu MCP servera sa omogućenim OAuthom nije potrebna posebna konfiguracija. Samo konfigurirajte udaljeni server: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp" + } + } +} +``` + +Ako server zahtijeva autentifikaciju, OpenCode će vas tražiti prijavu pri prvom korištenju. Ako se to ne desi, možete [ručno pokrenuti tok](#authenticating) naredbom `opencode mcp auth `. + +### Prethodno registrirano + +Ako imate klijentske vjerodajnice od dobavljača MCP servera, možete ih konfigurirati: + +```json title="opencode.json" {7-11} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "{env:MY_MCP_CLIENT_ID}", + "clientSecret": "{env:MY_MCP_CLIENT_SECRET}", + "scope": "tools:read tools:execute" + } + } + } +} +``` + +--- + +### Autentifikacija + +Možete ručno pokrenuti autentifikaciju ili upravljati vjerodajnicama. +Autentifikacija sa određenim MCP serverom: + +```bash +opencode mcp auth my-oauth-server +``` + +Navedite sve MCP servere i njihov status autentifikacije: + +```bash +opencode mcp list +``` + +Uklonite pohranjene vjerodajnice: + +```bash +opencode mcp logout my-oauth-server +``` + +Komanda `mcp auth` otvara pretraživač za autorizaciju. Nakon odobrenja, OpenCode sigurno čuva tokene u `~/.local/share/opencode/mcp-auth.json`. + +#### Onemogućavanje OAuth-a + +Ako želite onemogućiti automatski OAuth za server (npr. za servere koji umjesto toga koriste API ključeve), postavite `oauth` na `false`: + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-api-key-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": false, + "headers": { + "Authorization": "Bearer {env:MY_API_KEY}" + } + } + } +} +``` + +--- + +#### OAuth opcije + +| Opcija | Tip | Opis | +| -------------- | --------------- | --------------------------------------------------------------------------------------------- | +| `oauth` | Objekt \| false | OAuth konfiguracijski objekt, ili `false` da onemogućite automatsko otkrivanje OAuth. | +| `clientId` | String | ID OAuth klijenta. Ako nije navedeno, pokušat će se izvršiti dinamička registracija klijenta. | +| `clientSecret` | String | Tajna OAuth klijenta, ako to zahtijeva autorizacijski server. | +| `scope` | String | OAuth opseg zahtjeva za vrijeme autorizacije. | + +#### Otklanjanje grešaka + +Ako udaljeni MCP server ne uspije u autentifikaciji, možete dijagnosticirati probleme pomoću: + +```bash +# View auth status for all OAuth-capable servers +opencode mcp auth list + +# Debug connection and OAuth flow for a specific server +opencode mcp debug my-oauth-server +``` + +Komanda `mcp debug` prikazuje trenutni auth status, testira HTTP povezanost i pokušava OAuth discovery flow. + +## Upravljanje + +Vaši MCP serveri su dostupni kao alati u OpenCode, zajedno s ugrađenim alatima. Možete njima upravljati kroz OpenCode konfiguraciju kao i bilo kojim drugim alatom. + +### Globalno + +To znači da ih možete omogućiti ili onemogućiti globalno. + +```json title="opencode.json" {14} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-foo"] + }, + "my-mcp-bar": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-bar"] + } + }, + "tools": { + "my-mcp-foo": false + } +} +``` + +Također možemo koristiti glob obrazac da onemogućimo sve odgovarajuće MCP-ove. + +```json title="opencode.json" {14} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-foo"] + }, + "my-mcp-bar": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-bar"] + } + }, + "tools": { + "my-mcp*": false + } +} +``` + +Ovdje koristimo glob obrazac `my-mcp*` da onemogućimo sve MCP servere. + +### Po agentu + +Ako imate veliki broj MCP servera, možda ćete želeti da ih omogućite samo po agentu i da ih onemogućite globalno. Da biste to učinili: + +1. Onemogućite ga kao alat globalno. +2. U vašem [agent config](/docs/agents#tools), omogućite MCP server kao alat. + +```json title="opencode.json" {11, 14-18} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp": { + "type": "local", + "command": ["bun", "x", "my-mcp-command"], + "enabled": true + } + }, + "tools": { + "my-mcp*": false + }, + "agent": { + "my-agent": { + "tools": { + "my-mcp*": true + } + } + } +} +``` + +--- + +#### Glob uzorci + +Uzorak glob koristi jednostavne šablone globbiranja regularnih izraza: + +- `*` odgovara nuli ili više bilo kojeg znaka (npr. `"my-mcp*"` odgovara `my-mcp_search`, `my-mcp_list`, itd.) +- `?` odgovara tačno jednom znaku +- Svi ostali likovi se bukvalno podudaraju + :::note + MCP serverski alati se registruju sa imenom servera kao prefiksom, tako da onemogućite sve alate za server jednostavno koristite: + +``` +"mymcpservername_*": false +``` + +::: + +--- + +## Primjeri + +Ispod su primjeri uobičajenih MCP servera. Možete poslati PR ako želite dokumentovati druge servere. + +### Sentry + +Dodajte [Sentry MCP server](https://mcp.sentry.dev) za interakciju sa vašim Sentry projektima i problemima. + +```json title="opencode.json" {4-8} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "sentry": { + "type": "remote", + "url": "https://mcp.sentry.dev/mcp", + "oauth": {} + } + } +} +``` + +Nakon dodavanja konfiguracije, autentifikujte se sa Sentry: + +```bash +opencode mcp auth sentry +``` + +Ovo će otvoriti prozor pretraživača da završite OAuth tok i povežete OpenCode sa vašim Sentry nalogom. +Nakon provjere autentičnosti, možete koristiti Sentry alate u svojim upitima za upite o problemima, projektima i podacima o greškama. + +```txt "use sentry" +Show me the latest unresolved issues in my project. use sentry +``` + +--- + +### Context7 + +Dodajte [Context7 MCP server](https://github.com/upstash/context7) za pretraživanje dokumenata. + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp" + } + } +} +``` + +Ako ste se prijavili za besplatni račun, možete koristiti svoj API ključ i dobiti viša ograničenja stope. + +```json title="opencode.json" {7-9} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + } + } + } +} +``` + +Ovdje pretpostavljamo da imate postavljenu varijablu okruženja `CONTEXT7_API_KEY`. +Dodajte `use context7` vašim upitima za korištenje Context7 MCP servera. + +```txt "use context7" +Configure a Cloudflare Worker script to cache JSON API responses for five minutes. use context7 +``` + +Alternativno, možete dodati nešto poput ovoga na svoj [AGENTS.md](/docs/rules/). + +```md title="AGENTS.md" +When you need to search docs, use `context7` tools. +``` + +--- + +### Grep by Vercel + +Dodajte [Grep by Vercel](https://grep.app) MCP server za pretraživanje isječaka koda na GitHub. + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "gh_grep": { + "type": "remote", + "url": "https://mcp.grep.app" + } + } +} +``` + +Pošto smo naš MCP server nazvali `gh_grep`, možete dodati `use the gh_grep tool` u svoje upite da natjerate agenta da ga koristi. + +```txt "use the gh_grep tool" +What's the right way to set a custom domain in an SST Astro component? use the gh_grep tool +``` + +Alternativno, možete dodati nešto poput ovoga na svoj [AGENTS.md](/docs/rules/). + +```md title="AGENTS.md" +If you are unsure how to do something, use `gh_grep` to search code examples from GitHub. +``` diff --git a/packages/web/src/content/docs/bs/models.mdx b/packages/web/src/content/docs/bs/models.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b6099740bbc63e622f8a0ccecd76a505156aa2e4 --- /dev/null +++ b/packages/web/src/content/docs/bs/models.mdx @@ -0,0 +1,201 @@ +--- +title: Modeli +description: Konfiguriranje LLM provajdera i modela. +--- + +OpenCode koristi [AI SDK](https://ai-sdk.dev/) i [Models.dev](https://models.dev) za podršku **75+ LLM providera**, uključujući lokalne modele. + +## Provajderi + +Većina popularnih provajdera su unaprijed učitani prema zadanim postavkama. Ako ste dodali vjerodajnice za provajdera putem naredbe `/connect`, oni će biti dostupni kada pokrenete OpenCode. +Saznajte više o [providers](/docs/providers). + +--- + +## Odabir modela + +Nakon što konfigurirate svog provajdera, možete odabrati model koji želite upisivanjem: + +```bash frame="none" +/models +``` + +--- + +## Preporučeni modeli + +Postoji mnogo modela vani, a novi modeli izlaze svake sedmice. +:::tip +Razmislite o korištenju jednog od modela koje preporučujemo. +::: + +Međutim, postoji samo nekoliko njih koji su dobri i u generiranju koda i u pozivanju alata. +Evo nekoliko modela koji dobro rade sa OpenCode, bez posebnog redosleda. (Ovo nije potpuna lista niti je nužno ažurirana): + +- GPT 5.2 +- Codex GPT 5.1 +- Claude Opus 4.5 +- Claude Sonnet 4.5 +- Minimax M2.1 +- Gemini 3 Pro + +--- + +## Postavljanje zadanog + +Da postavite jedan od ovih kao zadani model, možete postaviti ključ `model` u svom +OpenCode config. + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "model": "lmstudio/google/gemma-3n-e4b" +} +``` + +Ovdje je puni ID `provider_id/model_id`. Na primjer, ako koristite [OpenCode Zen](/docs/zen), koristili biste `opencode/gpt-5.1-codex` za GPT 5.1 Codex. +Ako ste konfigurirali [prilagođenog provajdera](/docs/providers#custom), `provider_id` je ključ iz `provider` dijela vaše konfiguracije, a `model_id` je ključ iz `provider.models`. + +--- + +## Konfiguracija modela + +Možete globalno konfigurirati opcije modela kroz config. + +```jsonc title="opencode.jsonc" {7-12,19-24} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openai": { + "models": { + "gpt-5": { + "options": { + "reasoningEffort": "high", + "textVerbosity": "low", + "reasoningSummary": "auto", + "include": ["reasoning.encrypted_content"], + }, + }, + }, + }, + "anthropic": { + "models": { + "claude-sonnet-4-5-20250929": { + "options": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000, + }, + }, + }, + }, + }, + }, +} +``` + +Ovdje konfiguriramo globalne postavke za dva ugrađena modela: `gpt-5` kada se pristupa preko `openai` provajdera i `claude-sonnet-4-20250514` kada se pristupa preko `anthropic` provajdera. +Ugrađeni dobavljač i nazivi modela mogu se naći na [Models.dev](https://models.dev). +Također možete konfigurirati ove opcije za sve agente koje koristite. Konfiguracija agenta poništava sve globalne opcije ovdje. [Saznajte više](/docs/agents/#additional). +Također možete definirati prilagođene varijante koje proširuju ugrađene. Varijante vam omogućavaju da konfigurirate različite postavke za isti model bez stvaranja duplih unosa: + +```jsonc title="opencode.jsonc" {6-21} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "opencode": { + "models": { + "gpt-5": { + "variants": { + "high": { + "reasoningEffort": "high", + "textVerbosity": "low", + "reasoningSummary": "auto", + }, + "low": { + "reasoningEffort": "low", + "textVerbosity": "low", + "reasoningSummary": "auto", + }, + }, + }, + }, + }, + }, +} +``` + +--- + +## Varijante + +Mnogi modeli podržavaju više varijanti sa različitim konfiguracijama. OpenCode se isporučuje sa ugrađenim podrazumevanim varijantama za popularne provajdere. + +### Ugrađene varijante + +OpenCode se isporučuje sa zadanim varijantama za mnoge provajdere: +**Anthropic**: + +- `high` - Visok budžet za razmišljanje (zadano) +- `max` - Maksimalni budžet za razmišljanje + **OpenAI**: + Zavisi od modela, ali otprilike: +- `none` - Bez obrazloženja +- `minimal` - Minimalni napor za rasuđivanje +- `low` - Nizak napor u rasuđivanju +- `medium` - Srednji napor u zaključivanju +- `high` - Veliki napor u rasuđivanju +- `xhigh` - Ekstra visok napor u rasuđivanju + **Google**: +- `low` - Manji trud/budžet tokena +- `high` - Veći budžet za trud/token + :::tip + Ova lista nije sveobuhvatna. Mnogi drugi provajderi također imaju ugrađene zadane postavke. + ::: + +### Prilagođene varijante + +Možete nadjačati postojeće varijante ili dodati svoje: + +```jsonc title="opencode.jsonc" {7-18} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openai": { + "models": { + "gpt-5": { + "variants": { + "thinking": { + "reasoningEffort": "high", + "textVerbosity": "low", + }, + "fast": { + "disabled": true, + }, + }, + }, + }, + }, + }, +} +``` + +### Kruženje kroz varijante + +Koristite keybind `variant_cycle` za brzo prebacivanje između varijanti. [Saznajte više](/docs/keybinds). + +## Učitavanje modela + +Kada se OpenCode pokrene, on provjerava modele u sljedećem prioritetnom redoslijedu: + +1. Oznaka komandne linije `--model` ili `-m`. Format je isti kao u konfiguracijskoj datoteci: `provider_id/model_id`. +2. Lista modela u OpenCode konfiguraciji. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-20250514" +} +``` + +Format ovdje je `provider/model`. 3. Posljednji korišteni model. 4. Prvi model koji koristi interni prioritet. diff --git a/packages/web/src/content/docs/bs/network.mdx b/packages/web/src/content/docs/bs/network.mdx new file mode 100644 index 0000000000000000000000000000000000000000..aa213328426745e6d8f001111c7d13a98a52fac8 --- /dev/null +++ b/packages/web/src/content/docs/bs/network.mdx @@ -0,0 +1,51 @@ +--- +title: Mreža +description: Konfigurirajte proksije i prilagođene certifikate. +--- + +OpenCode podržava standardne proxy varijable okruženja i prilagođene certifikate za enterprise mrežna okruženja. + +## Proksi + +OpenCode poštuje standardne varijable proxy okruženja. + +```bash +# HTTPS proxy (recommended) +export HTTPS_PROXY=https://proxy.example.com:8080 + +# HTTP proxy (if HTTPS not available) +export HTTP_PROXY=http://proxy.example.com:8080 + +# Bypass proxy for local server (required) +export NO_PROXY=localhost,127.0.0.1 +``` + +:::caution +TUI komunicira sa lokalnim HTTP serverom. Morate zaobići proxy za ovu vezu kako biste spriječili petlje usmjeravanja. +::: + +Možete konfigurirati port servera i naziv hosta koristeći [CLI flags](/docs/cli#run). + +### Autentikacija + +Ako vaš proxy zahtijeva osnovnu autentifikaciju, uključite vjerodajnice u URL. + +```bash +export HTTPS_PROXY=http://username:password@proxy.example.com:8080 +``` + +:::caution +Izbjegavajte tvrdo kodiranje lozinki. Koristite varijable okruženja ili sigurno skladište vjerodajnica. +::: + +Za proxy servere koji zahtijevaju naprednu autentifikaciju poput NTLM ili Kerberos, razmotrite LLM Gateway koji podržava vašu metodu autentifikacije. + +## Prilagođeni certifikati + +Ako vaše preduzeće koristi prilagođene CA-ove za HTTPS veze, konfigurirajte OpenCode da im vjeruje. + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/ca-cert.pem +``` + +Ovo radi i za proxy veze i za direktan pristup API-ju. diff --git a/packages/web/src/content/docs/bs/permissions.mdx b/packages/web/src/content/docs/bs/permissions.mdx new file mode 100644 index 0000000000000000000000000000000000000000..4192694fe3ff97ed72796ae7a9f618cfb72e620e --- /dev/null +++ b/packages/web/src/content/docs/bs/permissions.mdx @@ -0,0 +1,228 @@ +--- +title: Dozvole +description: Kontrolirajte koje radnje zahtijevaju odobrenje za pokretanje. +--- + +OpenCode koristi `permission` konfiguraciju da odluči da li će se određena radnja pokrenuti automatski, zatražiti od vas ili biti blokirana. +Od `v1.1.1`, naslijeđena `tools` logička konfiguracija je zastarjela i spojena je u `permission`. Stara `tools` konfiguracija je još uvijek podržana za kompatibilnost unatrag. + +--- + +## Akcije + +Svako pravilo dozvole rješava jedno od: + +- `"allow"` — pokrenuti bez odobrenja +- `"ask"` — upit za odobrenje +- `"deny"` — blokiraj akciju + +--- + +## Konfiguracija + +Dozvole možete postaviti globalno (sa `*`) i nadjačati određene alate. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "*": "ask", + "bash": "allow", + "edit": "deny" + } +} +``` + +Također možete postaviti sve dozvole odjednom: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": "allow" +} +``` + +--- + +## Granularna pravila (sintaksa objekta) + +Za većinu dozvola, možete koristiti objekt za primjenu različitih radnji na osnovu unosa alata. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "npm *": "allow", + "rm *": "deny", + "grep *": "allow" + }, + "edit": { + "*": "deny", + "packages/web/src/content/docs/*.mdx": "allow" + } + } +} +``` + +Pravila se procjenjuju na osnovu podudaranja uzorka, pri čemu **pobjeđuje **poslednje odgovarajuće pravilo\*\_. Uobičajeni obrazac je da se prvo pravilo `"*"` stavi sveobuhvatno, a poslije njega konkretnija pravila. + +### Zamjenski znakovi + +Uzorci dozvola koriste jednostavno podudaranje zamjenskih znakova: + +- `*` odgovara nula ili više bilo kojeg znaka +- `?` odgovara tačno jednom znaku +- Svi ostali likovi se bukvalno podudaraju + +### Proširenje kućnog direktorija + +Možete koristiti `~` ili `$HOME` na početku obrasca da referencirate svoj početni direktorij. Ovo je posebno korisno za [`external_directory`](#external-directories) pravila. + +- `~/projects/*` -> `/Users/username/projects/*` +- `$HOME/projects/*` -> `/Users/username/projects/*` +- `~` -> `/Users/username` + +### Vanjski direktoriji + +Koristite `external_directory` da dozvolite pozive alata koji dodiruju putanje izvan radnog direktorija gdje je OpenCode pokrenut. Ovo se odnosi na bilo koji alat koji uzima putanju kao ulaz (na primjer `read`, `edit`, `glob`, `grep` i mnoge `bash` komande). +Proširenje kuće (poput `~/...`) utiče samo na način na koji je obrazac napisan. Ne čini vanjsku stazu dijelom trenutnog radnog prostora, tako da staze izvan radnog direktorija i dalje moraju biti dozvoljene preko `external_directory`. +Na primjer, ovo omogućava pristup svemu pod `~/projects/personal/`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "~/projects/personal/**": "allow" + } + } +} +``` + +Svaki direktorij koji je ovdje dozvoljen nasljeđuje iste zadane postavke kao trenutni radni prostor. Pošto je [`read` zadano na `allow`](#defaults), čitanje je također dozvoljeno za unose pod `external_directory` osim ako se ne poništi. Dodajte eksplicitna pravila kada bi alat trebao biti ograničen na ovim stazama, kao što je blokiranje uređivanja uz zadržavanje čitanja: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "~/projects/personal/**": "allow" + }, + "edit": { + "~/projects/personal/**": "deny" + } + } +} +``` + +Držite ovu listu fokusiranom na pouzdane putanje, a dodatna allow/deny pravila dodajte po potrebi za druge alate (npr. `bash`). + +## Dostupne dozvole + +Dozvole OpenCode su označene imenom alata, plus nekoliko sigurnosnih mjera: + +- `read` — čitanje datoteke (odgovara putanji datoteke) +- `edit` — sve izmjene fajlova (pokriva `edit`, `write`, `patch`) +- `glob` — globbiranje fajla (odgovara glob uzorku) +- `grep` — pretraga sadržaja (podudara se sa regularnim izrazom) +- `bash` — izvođenje komandi ljuske (podudara se s raščlanjenim komandama kao što je `git status --porcelain`) +- `task` — pokretanje subagenta (odgovara tipu podagenta) +- `skill` — učitavanje vještine (odgovara nazivu vještine) +- `lsp` — pokretanje LSP upita (trenutno negranularno) +- `webfetch` — dohvaćanje URL-a (odgovara URL-u) +- `websearch` — pretraživanje weba (odgovara upitu) +- `external_directory` — pokreće se kada alat dodirne staze izvan radnog direktorija projekta +- `doom_loop` — aktivira se kada se isti poziv alata ponovi 3 puta sa identičnim unosom + +--- + +## Zadane postavke + +Ako ništa ne navedete, OpenCode počinje od dozvoljenih zadanih vrijednosti: + +- Većina dozvola je zadana na `"allow"`. +- `doom_loop` i `external_directory` zadano na `"ask"`. +- `read` je `"allow"`, ali `.env` fajlovi su po defaultu odbijeni: + +```json title="opencode.json" +{ + "permission": { + "read": { + "*": "allow", + "*.env": "deny", + "*.env.*": "deny", + "*.env.example": "allow" + } + } +} +``` + +--- + +## Šta radi “Ask” + +Kada OpenCode zatraži odobrenje, korisničko sučelje nudi tri ishoda: + +- `once` — odobri samo ovaj zahtjev +- `always` — odobri buduće zahtjeve koji odgovaraju predloženim obrascima (za ostatak trenutne OpenCode sesije) +- `reject` — odbiti zahtjev + Skup obrazaca koje bi `always` odobrio pruža alat (na primjer, bash odobrenja obično stavljaju na bijelu listu sigurni prefiks komande kao što je `git status*`). + +--- + +## Agenti + +Možete nadjačati dozvole po agentu. Dozvole agenta su spojene sa globalnom konfiguracijom, a pravila agenta imaju prednost. [Saznajte više](/docs/agents#permissions) o dozvolama agenta. +:::note +Pogledajte gornji odjeljak [Granularna pravila (sintaksa objekata)](#granular-rules-object-syntax) za detaljnije primjere podudaranja uzoraka. +::: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "git commit *": "deny", + "git push *": "deny", + "grep *": "allow" + } + }, + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "git commit *": "ask", + "git push *": "deny", + "grep *": "allow" + } + } + } + } +} +``` + +Također možete konfigurirati dozvole agenta u Markdownu: + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Code review without edits +mode: subagent +permission: + edit: deny + bash: ask + webfetch: deny +--- + +Only analyze code and suggest changes. +``` + +:::tip +Koristite podudaranje uzoraka za naredbe s argumentima. `"grep *"` dozvoljava `grep pattern file.txt`, dok bi ga samo `"grep"` blokirao. Naredbe poput `git status` rade za zadano ponašanje, ali zahtijevaju eksplicitnu dozvolu (kao `"git status *"`) kada se prosljeđuju argumenti. +::: diff --git a/packages/web/src/content/docs/bs/plugins.mdx b/packages/web/src/content/docs/bs/plugins.mdx new file mode 100644 index 0000000000000000000000000000000000000000..7e046cb83dfd1e9211b3fe89606839e163442344 --- /dev/null +++ b/packages/web/src/content/docs/bs/plugins.mdx @@ -0,0 +1,371 @@ +--- +title: Dodaci +description: Napišite vlastite dodatke za proširenje OpenCode. +--- + +Dodaci vam omogućavaju da proširite OpenCode spajanjem na različite događaje i prilagođavanjem ponašanja. Možete kreirati dodatke za dodavanje novih funkcija, integraciju sa eksternim uslugama ili izmenu zadanog ponašanja OpenCode. +Za primjere, pogledajte [plugins](/docs/ecosystem#plugins) kreirane od strane zajednice. + +--- + +## Korištenje dodatka + +Postoje dva načina za učitavanje dodataka. + +### Iz lokalnih datoteka + +Postavite JavaScript ili TypeScript datoteke u direktorij dodataka. + +- `.opencode/plugins/` - Dodaci na nivou projekta +- `~/.config/opencode/plugins/` - Globalni dodaci + Datoteke u ovim direktorijumima se automatski učitavaju pri pokretanju. + +--- + +### Iz npm-a + +Navedite npm pakete u vašoj konfiguracijskoj datoteci. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-helicone-session", "opencode-wakatime", "@my-org/custom-plugin"] +} +``` + +Podržani su i regularni i npm paketi sa opsegom. +Pregledajte dostupne dodatke u [ecosystem](/docs/ecosystem#plugins). + +--- + +### Kako se instaliraju dodaci + +**npm dodaci** se instaliraju automatski pomoću Bun pri pokretanju. Paketi i njihove zavisnosti su keširani u `~/.cache/opencode/node_modules/`. +**Lokalni dodaci** se učitavaju direktno iz direktorija dodataka. Da biste koristili vanjske pakete, morate kreirati `package.json` unutar svog konfiguracijskog direktorija (pogledajte [Zavisnosti](#dependencies)) ili objaviti dodatak na npm i [dodati ga u svoju konfiguraciju](/docs/config#plugins). + +--- + +### Redoslijed učitavanja + +Dodaci se učitavaju iz svih izvora i svi zakačnjaci rade u nizu. Redoslijed učitavanja je: + +1. Globalna konfiguracija (`~/.config/opencode/opencode.json`) +2. Konfiguracija projekta (`opencode.json`) +3. Globalni direktorij dodataka (`~/.config/opencode/plugins/`) +4. Direktorij dodataka projekta (`.opencode/plugins/`) + Duplicirani npm paketi sa istim imenom i verzijom se učitavaju jednom. Međutim, lokalni dodatak i npm dodatak sa sličnim nazivima se učitavaju odvojeno. + +--- + +## Kreiranje dodatka + +Dodatak je **JavaScript/TypeScript modul** koji izvozi jedan ili više dodataka +funkcije. Svaka funkcija prima objekt konteksta i vraća hooks objekt. + +--- + +### Zavisnosti + +Lokalni dodaci i prilagođeni alati mogu koristiti vanjske npm pakete. Dodajte `package.json` u svoj konfiguracijski direktorij sa zavisnostima koje su vam potrebne. + +```json title=".opencode/package.json" +{ + "dependencies": { + "shescape": "^2.1.0" + } +} +``` + +OpenCode pokreće `bun install` pri pokretanju da ih instalira. Vaši dodaci i alati ih zatim mogu uvesti. + +```ts title=".opencode/plugins/my-plugin.ts" +import { escape } from "shescape" + +export const MyPlugin = async (ctx) => { + return { + "tool.execute.before": async (input, output) => { + if (input.tool === "bash") { + output.args.command = escape(output.args.command) + } + }, + } +} +``` + +--- + +### Osnovna struktura + +```js title=".opencode/plugins/example.js" +export const MyPlugin = async ({ project, client, $, directory, worktree }) => { + console.log("Plugin initialized!") + + return { + // Hook implementations go here + } +} +``` + +Funkcija dodatka prima: + +- `project`: Trenutne informacije o projektu. +- `directory`: Trenutni radni direktorij. +- `worktree`: Putanja git radnog stabla. +- `client`: Opencode SDK klijent za interakciju sa AI. +- `$`: Bun's [shell API](https://bun.com/docs/runtime/shell) za izvršavanje naredbi. + +--- + +### Podrška za TypeScript + +Za TypeScript dodatke, možete uvesti tipove iz paketa dodataka: + +```ts title="my-plugin.ts" {1} +import type { Plugin } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async ({ project, client, $, directory, worktree }) => { + return { + // Type-safe hook implementations + } +} +``` + +--- + +### Događaji + +Dodaci se mogu pretplatiti na događaje kao što je prikazano ispod u odjeljku Primjeri. Evo liste različitih dostupnih događaja. + +#### Komandni događaji + +- `command.executed` + +#### Događaji datoteka + +- `file.edited` +- `file.watcher.updated` + +#### Instalacijski događaji + +- `installation.updated` + +#### LSP događaji + +- `lsp.client.diagnostics` +- `lsp.updated` + +#### Događaji poruka + +- `message.part.removed` +- `message.part.updated` +- `message.removed` +- `message.updated` + +#### Događaji dozvola + +- `permission.asked` +- `permission.replied` + +#### Serverski događaji + +- `server.connected` + +#### Događaji sesije + +- `session.created` +- `session.compacted` +- `session.deleted` +- `session.diff` +- `session.error` +- `session.idle` +- `session.status` +- `session.updated` + +#### Todo događaji + +- `todo.updated` + +#### Shell događaji + +- `shell.env` + +#### Događaji alata + +- `tool.execute.after` +- `tool.execute.before` + +#### TUI događaji + +- `tui.prompt.append` +- `tui.command.execute` +- `tui.toast.show` + +--- + +## Primjeri + +Evo nekoliko primjera dodataka koje možete koristiti za proširenje OpenCode. + +### Slanje obavještenja + +Pošaljite obavještenja kada se dogode određeni događaji: + +```js title=".opencode/plugins/notification.js" +export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { + return { + event: async ({ event }) => { + // Send notification on session completion + if (event.type === "session.idle") { + await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + } + }, + } +} +``` + +Koristimo `osascript` za pokretanje AppleScript-a na macOS-u. Ovdje ga koristimo za slanje obavještenja. +:::note +Ako alat dodatka koristi isto ime kao ugrađeni alat, alat dodatka ima prednost. +::: + +--- + +### .env zaštita + +Spriječite opencode da čita `.env` fajlove: + +```javascript title=".opencode/plugins/env-protection.js" +export const EnvProtection = async ({ project, client, $, directory, worktree }) => { + return { + "tool.execute.before": async (input, output) => { + if (input.tool === "read" && output.args.filePath.includes(".env")) { + throw new Error("Do not read .env files") + } + }, + } +} +``` + +--- + +### Ubacivanje varijabli okruženja + +Ubacite varijable okruženja u sva izvršavanja ljuske (AI alati i korisnički terminali): + +```javascript title=".opencode/plugins/inject-env.js" +export const InjectEnvPlugin = async () => { + return { + "shell.env": async (input, output) => { + output.env.MY_API_KEY = "secret" + output.env.PROJECT_ROOT = input.cwd + }, + } +} +``` + +--- + +### Prilagođeni alati + +Dodaci također mogu dodati prilagođene alate u opencode: + +```ts title=".opencode/plugins/custom-tools.ts" +import { type Plugin, tool } from "@opencode-ai/plugin" + +export const CustomToolsPlugin: Plugin = async (ctx) => { + return { + tool: { + mytool: tool({ + description: "This is a custom tool", + args: { + foo: tool.schema.string(), + }, + async execute(args, context) { + const { directory, worktree } = context + return `Hello ${args.foo} from ${directory} (worktree: ${worktree})` + }, + }), + }, + } +} +``` + +Pomoćnik `tool` kreira prilagođeni alat koji opencode može pozvati. Uzima funkciju Zod sheme i vraća definiciju alata sa: + +- `description`: Šta alat radi +- `args`: Zod šema za argumente alata +- `execute`: Funkcija koja se pokreće kada se pozove alat + Vaši prilagođeni alati će biti dostupni za opencode zajedno sa ugrađenim alatima. + +--- + +### Bilježenje + +Koristite `client.app.log()` umjesto `console.log` za strukturirano bilježenje: + +```ts title=".opencode/plugins/my-plugin.ts" +export const MyPlugin = async ({ client }) => { + await client.app.log({ + body: { + service: "my-plugin", + level: "info", + message: "Plugin initialized", + extra: { foo: "bar" }, + }, + }) +} +``` + +Nivoi su: `debug`, `info`, `warn`, `error`. Pogledajte [SDK dokumentaciju](https://opencode.ai/docs/sdk) za detalje. + +### Kuke za sažimanje + +Prilagodite kontekst uključen kada se sesija zbije: + +```ts title=".opencode/plugins/compaction.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const CompactionPlugin: Plugin = async (ctx) => { + return { + "experimental.session.compacting": async (input, output) => { + // Inject additional context into the compaction prompt + output.context.push(` +## Custom Context + +Include any state that should persist across compaction: +- Current task status +- Important decisions made +- Files being actively worked on +`) + }, + } +} +``` + +`experimental.session.compacting` kuka se aktivira prije nego što LLM generira sažetak nastavka. Koristite ga za ubacivanje konteksta specifičnog za domenu koji bi zadani prompt za sažimanje propustio. +Također možete u potpunosti zamijeniti prompt za sabijanje postavljanjem `output.prompt`: + +```ts title=".opencode/plugins/custom-compaction.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const CustomCompactionPlugin: Plugin = async (ctx) => { + return { + "experimental.session.compacting": async (input, output) => { + // Replace the entire compaction prompt + output.prompt = ` +You are generating a continuation prompt for a multi-agent swarm session. + +Summarize: +1. The current task and its status +2. Which files are being modified and by whom +3. Any blockers or dependencies between agents +4. The next steps to complete the work + +Format as a structured prompt that a new agent can use to resume work. +` + }, + } +} +``` + +Kada je `output.prompt` postavljen, on u potpunosti zamjenjuje zadani prompt za sažimanje. Niz `output.context` se zanemaruje u ovom slučaju. diff --git a/packages/web/src/content/docs/bs/providers.mdx b/packages/web/src/content/docs/bs/providers.mdx new file mode 100644 index 0000000000000000000000000000000000000000..f6e54fc6ad158395bb3fac6fb2ffc383a47ba0ab --- /dev/null +++ b/packages/web/src/content/docs/bs/providers.mdx @@ -0,0 +1,1993 @@ +--- +title: Provajderi +description: Korištenje bilo kojeg LLM provajdera u OpenCode. +--- + +import config from "../../../../config.mjs" +export const console = config.console + +OpenCode koristi [AI SDK](https://ai-sdk.dev/) i [Models.dev](https://models.dev) za podršku **75+ LLM provajdera** i podržava pokretanje lokalnih modela. + +Za dodavanje provajdera potrebno je: + +1. Dodajte API ključeve za provajdera koristeći naredbu `/connect`. +2. Konfigurirajte dobavljača u vašoj OpenCode konfiguraciji. + +--- + +### Vjerodajnice + +Kada dodate API ključeve dobavljača sa naredbom `/connect`, oni se pohranjuju +u `~/.local/share/opencode/auth.json`. + +--- + +### Konfiguracija + +Možete prilagoditi dobavljače putem odjeljka `provider` u vašem OpenCode +config. + +--- + +#### Osnovni URL + +Možete prilagoditi osnovni URL za bilo kojeg provajdera postavljanjem opcije `baseURL`. Ovo je korisno kada koristite proxy usluge ili prilagođene krajnje tačke. + +```json title="opencode.json" {6} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "baseURL": "https://api.anthropic.com/v1" + } + } + } +} +``` + +--- + +## OpenCode Zen + +OpenCode Zen je lista modela koje je obezbedio OpenCode tim koji su bili +testirano i potvrđeno da dobro radi sa OpenCode. [Saznajte više](/docs/zen). + +:::tip +Ako ste novi, preporučujemo da počnete sa OpenCode Zen. +::: + +1. Pokrenite naredbu `/connect` u TUI-u, odaberite opencode i idite na [opencode.ai/auth](https://opencode.ai/auth). + +```txt + /connect +``` + +2. Prijavite se, dodajte svoje detalje naplate i kopirajte svoj API ključ. + +3. Zalijepite svoj API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite `/models` u TUI da vidite listu modela koje preporučujemo. + +```txt + /models +``` + +Radi kao i svaki drugi provajder u OpenCode i potpuno je opcionalan za korištenje. + +--- + +## OpenCode Go + +OpenCode Go je jeftin plan pretplate koji pruža pouzdan pristup popularnim modelima otvorenog kodiranja koje pruža OpenCode tim i koji su testirani i verificirani da dobro rade s OpenCode-om. + +1. Pokrenite naredbu `/connect` u TUI-u, odaberite `OpenCode Go` i idite na [opencode.ai/auth](https://opencode.ai/zen). + + ```txt + /connect + ``` + +2. Prijavite se, dodajte svoje detalje naplate i kopirajte svoj API ključ. + +3. Zalijepite svoj API ključ. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Pokrenite naredbu `/models` u TUI da vidite listu modela koje preporučujemo. + + ```txt + /models + ``` + +Radi kao i svaki drugi provajder u OpenCode i potpuno je opcionalan za korištenje. + +--- + +## Direktorij + +Pogledajmo neke od provajdera detaljno. Ako želite dodati provajdera na +listu, slobodno otvori PR. + +:::note +Ne vidite provajdera ovdje? Pošaljite PR. +::: + +--- + +### 302.AI + +1. Idite na [302.AI konzolu](https://302.ai/), kreirajte račun i generirajte API ključ. + +2. Pokrenite naredbu `/connect` i potražite **302.AI**. + +```txt + /connect +``` + +3. Unesite svoj 302.AI API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model. + +```txt + /models +``` + +--- + +### Amazon Bedrock + +Da biste koristili Amazon Bedrock s OpenCode: + +1. Idite na **Katalog modela** na Amazon Bedrock konzoli i zatražite + pristup modelima koje želite. + + :::tip + Morate imati pristup modelu koji želite u Amazon Bedrock. + ::: + +2. **Konfigurirajte autentifikaciju** koristeći jedan od sljedećih metoda: + + #### Varijable okruženja (Brzi početak) + + Postavite jednu od ovih varijabli okruženja dok pokrećete opencode: + +```bash + # Option 1: Using AWS access keys + AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=YYY opencode + + # Option 2: Using named AWS profile + AWS_PROFILE=my-profile opencode + + # Option 3: Using Bedrock bearer token + AWS_BEARER_TOKEN_BEDROCK=XXX opencode +``` + +Ili ih dodajte na svoj bash profil: + +```bash title="~/.bash_profile" + export AWS_PROFILE=my-dev-profile + export AWS_REGION=us-east-1 +``` + +#### Konfiguracijski fajl (Preporučeno) + +Za konfiguraciju specifičnu za projekat ili trajnu konfiguraciju, koristite `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "my-aws-profile" + } + } + } +} +``` + +**Dostupne opcije:** + +- `region` - ​​AWS regija (npr. `us-east-1`, `eu-west-1`) +- `profile` - ​​AWS je imenovao profil od `~/.aws/credentials` +- `endpoint` - ​​URL prilagođene krajnje tačke za VPC krajnje tačke (pseudonim za generičku opciju `baseURL`) + +:::tip +Opcije konfiguracijske datoteke imaju prednost nad varijablama okruženja. +::: + +#### Napredno: VPC krajnje tačke + +Ako koristite VPC krajnje tačke za Bedrock: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "production", + "endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" + } + } + } +} +``` + +:::note +Opcija `endpoint` je pseudonim za generičku opciju `baseURL`, koristeći terminologiju specifičnu za AWS. Ako su specificirani i `endpoint` i `baseURL`, `endpoint` ima prednost. +::: + +#### Metode provjere autentičnosti + +- **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**: Kreirajte IAM korisnika i generirajte pristupne ključeve u AWS konzoli +- **`AWS_PROFILE`**: Koristite imenovane profile od `~/.aws/credentials`. Prvo konfigurirajte sa `aws configure --profile my-profile` ili `aws sso login` +- **`AWS_BEARER_TOKEN_BEDROCK`**: Generirajte dugoročne API ključeve sa Amazon Bedrock konzole +- **`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`**: Za EKS IRSA (IAM uloge za servisne naloge) ili druga Kubernetes okruženja sa OIDC federacijom. Kubernetes automatski ubacuje ove varijable okruženja kada se koriste napomene naloga usluge. + +#### Prioritet autentifikacije + +Amazon Bedrock koristi sljedeći prioritet autentifikacije: + +1. **Token nosioca** - `AWS_BEARER_TOKEN_BEDROCK` varijabla okruženja ili token iz naredbe `/connect` +2. **AWS lanac vjerodajnica** - profil, pristupni ključevi, dijeljeni vjerodajnici, IAM uloge, tokeni web identiteta (EKS IRSA), metapodaci instance + +:::note +Kada se postavi token nosioca (putem `/connect` ili `AWS_BEARER_TOKEN_BEDROCK`), on ima prednost nad svim AWS metodama akreditiva uključujući konfigurirane profile. +::: + +3. Pokrenite naredbu `/models` da odaberete model koji želite. + +```txt + /models +``` + +:::note +Za prilagođene profile zaključivanja, koristite ime modela i dobavljača u ključu i postavite svojstvo `id` na arn. Ovo osigurava ispravno keširanje: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + // ... + "models": { + "anthropic-claude-sonnet-4.5": { + "id": "arn:aws:bedrock:us-east-1:xxx:application-inference-profile/yyy" + } + } + } + } +} +``` + +::: + +--- + +### Anthropic + +1. Nakon što ste se prijavili, pokrenite naredbu `/connect` i odaberite Anthropic. + +```txt + /connect +``` + +2. Ovdje možete odabrati opciju **Claude Pro/Max** i ona će otvoriti vaš pretraživač + i traži od vas da se autentifikujete. + +```txt + ┌ Select auth method + │ + │ Claude Pro/Max + │ Create an API Key + │ Manually enter API Key + └ +``` + +3. Sada bi svi Anthropic modeli trebali biti dostupni kada koristite naredbu `/models`. + +```txt + /models +``` + +:::info +[Anthropic] (https://anthropic.com) službeno ne podržava korištenje vaše Claude Pro/Max pretplate u OpenCode. +::: + +##### Korištenje API ključeva + +Također možete odabrati **Kreiraj API ključ** ako nemate Pro/Max pretplatu. Također će otvoriti vaš pretraživač i zatražiti od vas da se prijavite na Anthropic i dati vam kod koji možete zalijepiti u svoj terminal. + +Ili ako već imate API ključ, možete odabrati **Ručno unesite API ključ** i zalijepite ga u svoj terminal. + +--- + +### Atomic Chat + +Možete konfigurirati opencode za korištenje lokalnih modela preko [Atomic Chata](https://atomic.chat) — desktop aplikacije koja pokreće lokalne LLM-ove iza OpenAI-kompatibilnog API servera (zadana krajnja tačka `http://127.0.0.1:1337/v1`). + +```json title="opencode.json" "atomic-chat" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "atomic-chat": { + "npm": "@ai-sdk/openai-compatible", + "name": "Atomic Chat (local)", + "options": { + "baseURL": "http://127.0.0.1:1337/v1" + }, + "models": { + "": { + "name": "" + } + } + } + } +} +``` + +U ovom primjeru: + +- `atomic-chat` je prilagođeni ID provajdera. Može biti bilo koji niz. +- `npm` specificira paket koji se koristi za ovog provajdera. Ovdje se koristi `@ai-sdk/openai-compatible` za svaki OpenAI-kompatibilni API. +- `name` je prikazano ime provajdera u interfejsu. +- `options.baseURL` je krajnja tačka lokalnog servera. Promijenite host i port da odgovaraju vašoj Atomic Chat konfiguraciji. +- `models` je mapa ID-ova modela u njihova prikazana imena. Svaki ID mora odgovarati `id` vrijednosti koju vraća `GET /v1/models` — pokrenite `curl http://127.0.0.1:1337/v1/models` da vidite ID-ove trenutno učitane u Atomic Chat. + +:::tip +Ako pozivi alata ne rade dobro, odaberite učitani model sa jakom podrškom za tool calling (na primjer, Qwen-Coder ili DeepSeek-Coder varijantu). +::: + +--- + +### Azure OpenAI + +:::note +Ako naiđete na greške "Žao mi je, ali ne mogu pomoći s tim zahtjevom", pokušajte promijeniti filter sadržaja iz **DefaultV2** u **Default** u vašem Azure resursu. +::: + +1. Idite na [Azure portal](https://portal.azure.com/) i kreirajte **Azure OpenAI** resurs. trebat će vam: + - **Naziv resursa**: Ovo postaje dio vaše krajnje tačke API-ja (`https://RESOURCE_NAME.openai.azure.com/`) + - **API ključ**: Ili `KEY 1` ili `KEY 2` sa vašeg izvora + +2. Idite na [Azure AI Foundry](https://ai.azure.com/) i implementirajte model. + + :::note + Ime implementacije mora odgovarati imenu modela da bi opencode ispravno radio. + ::: + +3. Pokrenite naredbu `/connect` i potražite **Azure**. + +```txt + /connect +``` + +4. Unesite svoj API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +5. Postavite ime vašeg resursa kao varijablu okruženja: + +```bash + AZURE_RESOURCE_NAME=XXX opencode +``` + +Ili ga dodajte na svoj bash profil: + +```bash title="~/.bash_profile" + export AZURE_RESOURCE_NAME=XXX +``` + +6. Pokrenite naredbu `/models` da odaberete svoj raspoređeni model. + +```txt + /models +``` + +--- + +### Azure Cognitive Services + +1. Idite na [Azure portal](https://portal.azure.com/) i kreirajte **Azure OpenAI** resurs. trebat će vam: + - **Naziv resursa**: Ovo postaje dio vaše krajnje tačke API-ja (`https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/`) + - **API ključ**: Ili `KEY 1` ili `KEY 2` sa vašeg izvora + +2. Idite na [Azure AI Foundry](https://ai.azure.com/) i implementirajte model. + + :::note + Ime implementacije mora odgovarati imenu modela da bi opencode ispravno radio. + ::: + +3. Pokrenite naredbu `/connect` i potražite **Azure kognitivne usluge**. + +```txt + /connect +``` + +4. Unesite svoj API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +5. Postavite ime vašeg resursa kao varijablu okruženja: + +```bash + AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX opencode +``` + +Ili ga dodajte na svoj bash profil: + +```bash title="~/.bash_profile" + export AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX +``` + +6. Pokrenite naredbu `/models` da odaberete svoj raspoređeni model. + +```txt + /models +``` + +--- + +### Baseten + +1. Idite na [Baseten](https://app.baseten.co/), kreirajte račun i generirajte API ključ. + +2. Pokrenite naredbu `/connect` i potražite **Baseten**. + +```txt + /connect +``` + +3. Unesite svoj Baseten API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model. + +```txt + /models +``` + +--- + +### Cerebras + +1. Idite na [Cerebras konzolu](https://inference.cerebras.ai/), kreirajte račun i generirajte API ključ. + +2. Pokrenite naredbu `/connect` i potražite **Cerebras**. + +```txt + /connect +``` + +3. Unesite svoj Cerebras API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Qwen 3 Coder 480B_. + +```txt + /models +``` + +--- + +### Cloudflare AI Gateway + +Cloudflare AI Gateway vam omogućava da pristupite modelima iz OpenAI, Anthropic, Workers AI i više preko objedinjene krajnje tačke. Sa [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) nisu vam potrebni posebni API ključevi za svakog provajdera. + +1. Idite na [Cloudflare kontrolnu tablu](https://dash.cloudflare.com/), idite na **AI** > **AI Gateway** i kreirajte novi pristupnik. + +2. Postavite svoj ID naloga i ID pristupnika kao varijable okruženja. + +```bash title="~/.bash_profile" + export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id + export CLOUDFLARE_GATEWAY_ID=your-gateway-id +``` + +3. Pokrenite naredbu `/connect` i potražite **Cloudflare AI Gateway**. + +```txt + /connect +``` + +4. Unesite svoj Cloudflare API token. + +```txt + ┌ API key + │ + │ + └ enter +``` + +Ili ga postavite kao varijablu okruženja. + +```bash title="~/.bash_profile" + export CLOUDFLARE_API_TOKEN=your-api-token +``` + +5. Pokrenite naredbu `/models` da odaberete model. + +```txt + /models +``` + +Također možete dodati modele kroz svoju opencode konfiguraciju. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "cloudflare-ai-gateway": { + "models": { + "openai/gpt-4o": {}, + "anthropic/claude-sonnet-4": {} + } + } + } +} +``` + +--- + +### Cortecs + +1. Idite na [Cortecs konzolu](https://cortecs.ai/), kreirajte račun i generirajte API ključ. + +2. Pokrenite naredbu `/connect` i potražite **Cortecs**. + +```txt + /connect +``` + +3. Unesite svoj Cortecs API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Kimi K2 Instruct_. + +```txt + /models +``` + +--- + +### DeepSeek + +1. Idite na [DeepSeek konzolu](https://platform.deepseek.com/), kreirajte nalog i kliknite na **Kreiraj novi API ključ**. + +2. Pokrenite naredbu `/connect` i potražite **DeepSeek**. + +```txt + /connect +``` + +3. Unesite svoj DeepSeek API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete DeepSeek model kao što je _DeepSeek V4 Pro_. + +```txt + /models +``` + +--- + +### Deep Infra + +1. Idite na [Deep Infra kontrolnu tablu](https://deepinfra.com/dash), kreirajte nalog i generišite API ključ. + +2. Pokrenite naredbu `/connect` i potražite **Deep Infra**. + +```txt + /connect +``` + +3. Unesite svoj Deep Infra API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model. + +```txt + /models +``` + +--- + +### FrogBot + +1. Idite na [kontrolnu tablu firmvera](https://app.frogbot.ai/signup), kreirajte nalog i generišite API ključ. + +2. Pokrenite naredbu `/connect` i potražite **FrogBot**. + +```txt + /connect +``` + +3. Unesite svoj FrogBot API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model. + +```txt + /models +``` + +--- + +### Fireworks AI + +1. Idite na [Fireworks AI konzolu](https://app.fireworks.ai/), kreirajte račun i kliknite na **Kreiraj API ključ**. + +2. Pokrenite naredbu `/connect` i potražite **Fireworks AI**. + +```txt + /connect +``` + +3. Unesite svoj Fireworks AI API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Kimi K2 Instruct_. + +```txt + /models +``` + +--- + +### GitLab Duo + +GitLab Duo pruža agentsko ćaskanje sa AI-om sa izvornim mogućnostima pozivanja alata preko GitLab-ovog Anthropic proxyja. + +1. Pokrenite naredbu `/connect` i odaberite GitLab. + +```txt + /connect +``` + +2. Odaberite svoj način autentifikacije: + +```txt + ┌ Select auth method + │ + │ OAuth (Recommended) + │ Personal Access Token + └ +``` + +#### Korištenje OAuth-a (preporučeno) + +Odaberite **OAuth** i vaš pretraživač će se otvoriti za autorizaciju. + +#### Korištenje tokena ličnog pristupa + +1. Idite na [GitLab korisničke postavke > Pristupni tokeni](https://gitlab.com/-/user_settings/personal_access_tokens) +2. Kliknite **Dodaj novi token** +3. Naziv: `OpenCode`, opseg: `api` +4. Kopirajte token (počinje sa `glpat-`) +5. Unesite ga u terminal + +6. Pokrenite naredbu `/models` da vidite dostupne modele. + +```txt + /models +``` + +Dostupna su tri modela bazirana na Claudeu: + +- **duo-chat-haiku-4-5** (zadano) - Brzi odgovori za brze zadatke +- **duo-chat-sonnet-4-5** - Uravnotežene performanse za većinu tokova posla +- **duo-chat-opus-4-5** - Najsposobniji za kompleksnu analizu + +:::note +Također možete odrediti 'GITLAB_TOKEN' varijablu okruženja ako ne želite +da pohrani token u opencode auth memoriju. +::: + +##### Samostalni GitLab + +:::note[Napomena o usklađenosti] +OpenCode koristi mali model za neke AI zadatke kao što je generiranje naslova sesije. +Podrazumevano je konfigurisan da koristi gpt-5-nano, a hostuje ga Zen. Da zaključate OpenCode +da biste koristili samo svoju vlastitu instancu koju hostuje GitLab, dodajte sljedeće u svoju +`opencode.json` fajl. Također se preporučuje da onemogućite dijeljenje sesije. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "small_model": "gitlab/duo-chat-haiku-4-5", + "share": "disabled" +} +``` + +::: + +Za GitLab instance koje hostuju sami: + +```bash +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_TOKEN=glpat-... +``` + +Ako vaša instanca pokreće prilagođeni AI Gateway: + +```bash +GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com +``` + +Ili dodajte na svoj bash profil: + +```bash title="~/.bash_profile" +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com +export GITLAB_TOKEN=glpat-... +``` + +:::note +Vaš GitLab administrator mora omogućiti sljedeće: + +1. [Duo Agent Platforma](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) za korisnika, grupu ili instancu +2. Zastavice funkcija (preko Rails konzole): + - `agent_platform_claude_code` + - `third_party_agents_enabled` + ::: + +##### OAuth za self-hosted instance + +Da bi Oauth radio za vašu instancu koju sami hostujete, morate kreirati +novu aplikaciju (Podešavanja → Aplikacije) sa +URL povratnog poziva `http://127.0.0.1:8080/callback` i sljedeći opseg: + +- api (pristupite API-ju u svoje ime) +- read_user (Pročitajte svoje lične podatke) +- read_repository (omogućava pristup spremištu samo za čitanje) + +Zatim izložite ID aplikacije kao varijablu okruženja: + +```bash +export GITLAB_OAUTH_CLIENT_ID=your_application_id_here +``` + +Više dokumentacije na početnoj stranici [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). + +##### Konfiguracija + +Prilagodite putem `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "gitlab": { + "options": { + "instanceUrl": "https://gitlab.com" + } + } + } +} +``` + +##### GitLab API alati (opciono, ali se preporučuje) + +Za pristup GitLab alatima (zahtjevi za spajanje, problemi, cjevovodi, CI/CD, itd.): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-gitlab-plugin"] +} +``` + +Ovaj dodatak pruža sveobuhvatne mogućnosti upravljanja GitLab repozitorijumom, uključujući MR preglede, praćenje problema, praćenje procesa i još mnogo toga. + +--- + +### GitHub Copilot + +Da biste koristili svoju GitHub Copilot pretplatu s opencode: + +:::note +Neki modeli će možda trebati [Pro+ +pretplata](https://github.com/features/copilot/plans) za korištenje. + +Neki modeli moraju biti ručno omogućeni u vašim [postavkama GitHub Copilot](https://docs.github.com/en/copilot/how-tos/use-ai-models/configure-access-to-ai-models#setup-for-individual-use). +::: + +1. Pokrenite naredbu `/connect` i potražite GitHub Copilot. + +```txt + /connect +``` + +2. Idite na [github.com/login/device](https://github.com/login/device) i unesite kod. + +```txt + ┌ Login with GitHub Copilot + │ + │ https://github.com/login/device + │ + │ Enter code: 8F43-6FCF + │ + └ Waiting for authorization... +``` + +3. Sada pokrenite naredbu `/models` da odaberete model koji želite. + +```txt + /models +``` + +--- + +### Google Vertex AI + +Za korištenje Google Vertex AI s OpenCode: + +1. Idite do **Model Garden** u Google Cloud Console i provjerite + modeli dostupni u vašoj regiji. + + :::note + Morate imati Google Cloud projekat sa omogućenim Vertex AI API. + ::: + +2. Postavite potrebne varijable okruženja: + - `GOOGLE_CLOUD_PROJECT`: ID vašeg Google Cloud projekta + - `VERTEX_LOCATION` (opciono): Region za Vertex AI (podrazumevano na `global`) + - Autentifikacija (odaberite jednu): + - `GOOGLE_APPLICATION_CREDENTIALS`: Put do JSON ključnog fajla vašeg naloga usluge + - Autentifikacija koristeći gcloud CLI: `gcloud auth application-default login` + + Postavite ih dok se pokreće opencode. + +```bash + GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json GOOGLE_CLOUD_PROJECT=your-project-id opencode +``` + +Ili ih dodajte svom bash profilu. + +```bash title="~/.bash_profile" + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json + export GOOGLE_CLOUD_PROJECT=your-project-id + export VERTEX_LOCATION=global +``` + +:::tip +Regija `global` poboljšava dostupnost i smanjuje greške bez dodatnih troškova. Koristite regionalne krajnje tačke (npr. `us-central1`) za zahtjeve rezidentnosti podataka. [Saznajte više](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models#regional_and_global_endpoints) +::: + +3. Pokrenite naredbu `/models` da odaberete model koji želite. + +```txt + /models +``` + +--- + +### Groq + +1. Idite na [Groq konzolu](https://console.groq.com/), kliknite **Kreiraj API ključ** i kopirajte ključ. + +2. Pokrenite naredbu `/connect` i potražite Groq. + +```txt + /connect +``` + +3. Unesite API ključ za provajdera. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete onu koju želite. + +```txt + /models +``` + +--- + +### Hugging Face + +[Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers) omogućava pristup otvorenim modelima koje podržava 17+ provajdera. + +1. Idite na [Postavke zagrljaja](https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained) da kreirate token s dozvolom za upućivanje poziva dobavljačima inference. + +2. Pokrenite naredbu `/connect` i potražite **Hugging Face**. + +```txt + /connect +``` + +3. Unesite svoj token Hugging Face. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Kimi-K2-Instruct_ ili _GLM-4.6_. + +```txt + /models +``` + +--- + +### Helicone + +[Helicone](https://helicone.ai) je platforma za praćenje LLM koja pruža evidenciju, praćenje i analitiku za vaše AI aplikacije. Helicone AI Gateway automatski usmjerava vaše zahtjeve do odgovarajućeg provajdera na osnovu modela. + +1. Idite na [Helicone](https://helicone.ai), kreirajte račun i generirajte API ključ sa svoje kontrolne table. + +2. Pokrenite naredbu `/connect` i potražite **Helicone**. + +```txt + /connect +``` + +3. Unesite svoj Helicone API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model. + +```txt + /models +``` + +Za više provajdera i napredne funkcije kao što su keširanje i ograničavanje brzine, provjerite [Helicone dokumentaciju](https://docs.helicone.ai). + +#### Opcione konfiguracije + +U slučaju da vidite funkciju ili model iz Helicone-a koji nije automatski konfiguriran putem opencodea, uvijek ga možete sami konfigurirati. + +Evo [Heliconeov katalog modela](https://helicone.ai/models), ovo će vam trebati da preuzmete ID-ove modela koje želite dodati. + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + }, + "models": { + "gpt-4o": { + // Model ID (from Helicone's model directory page) + "name": "GPT-4o", // Your own custom name for the model + }, + "claude-sonnet-4-20250514": { + "name": "Claude Sonnet 4", + }, + }, + }, + }, +} +``` + +#### Prilagođena zaglavlja + +Helicone podržava prilagođena zaglavlja za funkcije kao što su keširanje, praćenje korisnika i upravljanje sesijom. Dodajte ih u konfiguraciju svog provajdera koristeći `options.headers`: + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + "headers": { + "Helicone-Cache-Enabled": "true", + "Helicone-User-Id": "opencode", + }, + }, + }, + }, +} +``` + +##### Praćenje sesije + +Heliconeova funkcija [Sessions](https://docs.helicone.ai/features/sessions) vam omogućava da grupišete povezane LLM zahtjeve zajedno. Koristite dodatak [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) da automatski prijavite svaki OpenCode razgovor kao sesiju u Helicone-u. + +```bash +npm install -g opencode-helicone-session +``` + +Dodajte ga u svoju konfiguraciju. + +```json title="opencode.json" +{ + "plugin": ["opencode-helicone-session"] +} +``` + +Dodatak ubacuje zaglavlja `Helicone-Session-Id` i `Helicone-Session-Name` u vaše zahtjeve. Na stranici Helicone Sessions, vidjet ćete svaki OpenCode razgovor naveden kao zasebna sesija. + +##### Uobičajena Helicone zaglavlja + +| Header | Opis | +| -------------------------- | ------------------------------------------------------------------- | +| `Helicone-Cache-Enabled` | Omogući keširanje odgovora (`true`/`false`) | +| `Helicone-User-Id` | Pratite metriku po korisniku | +| `Helicone-Property-[Name]` | Dodajte prilagođena svojstva (npr. `Helicone-Property-Environment`) | +| `Helicone-Prompt-Id` | Povezivanje zahtjeva sa brzim verzijama | + +Pogledajte [Helicone Header Directory](https://docs.helicone.ai/helicone-headers/header-directory) za sva dostupna zaglavlja. + +--- + +### llama.cpp + +Možete konfigurirati opencode za korištenje lokalnih modela putem [llama.cpp's](https://github.com/ggml-org/llama.cpp) uslužnog programa llama-server + +```json title="opencode.json" "llama.cpp" {5, 6, 8, 10-15} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "llama.cpp": { + "npm": "@ai-sdk/openai-compatible", + "name": "llama-server (local)", + "options": { + "baseURL": "http://127.0.0.1:8080/v1" + }, + "models": { + "qwen3-coder:a3b": { + "name": "Qwen3-Coder: a3b-30b (local)", + "limit": { + "context": 128000, + "output": 65536 + } + } + } + } + } +} +``` + +U ovom primjeru: + +- `llama.cpp` je ID prilagođenog provajdera. Ovo može biti bilo koji niz koji želite. +- `npm` specificira paket koji će se koristiti za ovog provajdera. Ovdje se `@ai-sdk/openai-compatible` koristi za bilo koji OpenAI kompatibilan API. +- `name` je ime za prikaz za provajdera u korisničkom sučelju. +- `options.baseURL` je krajnja tačka za lokalni server. +- `models` je mapa ID-ova modela prema njihovim konfiguracijama. Naziv modela će biti prikazan na listi za odabir modela. + +--- + +### IO.NET + +IO.NET nudi 17 modela optimiziranih za različite slučajeve upotrebe: + +1. Idite na [IO.NET konzolu](https://ai.io.net/), kreirajte račun i generirajte API ključ. + +2. Pokrenite naredbu `/connect` i potražite **IO.NET**. + +```txt + /connect +``` + +3. Unesite svoj IO.NET API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model. + +```txt + /models +``` + +--- + +### LM Studio + +Možete konfigurirati opencode za korištenje lokalnih modela preko LM Studio. + +```json title="opencode.json" "lmstudio" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "lmstudio": { + "npm": "@ai-sdk/openai-compatible", + "name": "LM Studio (local)", + "options": { + "baseURL": "http://127.0.0.1:1234/v1" + }, + "models": { + "google/gemma-3n-e4b": { + "name": "Gemma 3n-e4b (local)" + } + } + } + } +} +``` + +U ovom primjeru: + +- `lmstudio` je ID prilagođenog provajdera. Ovo može biti bilo koji niz koji želite. +- `npm` specificira paket koji će se koristiti za ovog provajdera. Ovdje se `@ai-sdk/openai-compatible` koristi za bilo koji OpenAI kompatibilan API. +- `name` je ime za prikaz za provajdera u korisničkom sučelju. +- `options.baseURL` je krajnja tačka za lokalni server. +- `models` je mapa ID-ova modela prema njihovim konfiguracijama. Naziv modela će biti prikazan na listi za odabir modela. + +--- + +### Moonshot AI + +Da biste koristili Kimi K2 iz Moonshot AI: + +1. Idite na [Moonshot AI konzolu](https://platform.moonshot.ai/console), kreirajte nalog i kliknite na **Kreiraj API ključ**. + +2. Pokrenite naredbu `/connect` i potražite **Moonshot AI**. + +```txt + /connect +``` + +3. Unesite svoj Moonshot API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete _Kimi K2_. + +```txt + /models +``` + +--- + +### MiniMax + +1. Prijeđite na [MiniMax API konzolu](https://platform.minimax.io/login), kreirajte račun i generirajte API ključ. + +2. Pokrenite naredbu `/connect` i potražite **MiniMax**. + +```txt + /connect +``` + +3. Unesite svoj MiniMax API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _M2.1_. + +```txt + /models +``` + +--- + +### Nebius Token Factory + +1. Idite na [Nebius Token Factory konzolu](https://tokenfactory.nebius.com/), kreirajte nalog i kliknite na **Dodaj ključ**. + +2. Pokrenite naredbu `/connect` i potražite **Nebius Token Factory**. + +```txt + /connect +``` + +3. Unesite svoj Nebius Token Factory API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Kimi K2 Instruct_. + +```txt + /models +``` + +--- + +### Ollama + +Možete konfigurirati opencode za korištenje lokalnih modela putem Ollame. + +:::tip +Ollama se može automatski konfigurirati za OpenCode. Pogledajte [Ollama integracijske dokumente](https://docs.ollama.com/integrations/opencode) za detalje. +::: + +```json title="opencode.json" "ollama" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "ollama": { + "npm": "@ai-sdk/openai-compatible", + "name": "Ollama (local)", + "options": { + "baseURL": "http://localhost:11434/v1" + }, + "models": { + "llama2": { + "name": "Llama 2" + } + } + } + } +} +``` + +U ovom primjeru: + +- `ollama` je ID prilagođenog provajdera. Ovo može biti bilo koji niz koji želite. +- `npm` specificira paket koji će se koristiti za ovog provajdera. Ovdje se `@ai-sdk/openai-compatible` koristi za bilo koji OpenAI kompatibilan API. +- `name` je ime za prikaz za provajdera u korisničkom sučelju. +- `options.baseURL` je krajnja tačka za lokalni server. +- `models` je mapa ID-ova modela prema njihovim konfiguracijama. Naziv modela će biti prikazan na listi za odabir modela. + +:::tip +Ako pozivi alata ne rade, pokušajte povećati `num_ctx` u Ollama. Počnite oko 16k - 32k. +::: + +--- + +### Ollama Cloud + +Da biste koristili Ollama Cloud s OpenCode: + +1. Idite na [https://ollama.com/](https://ollama.com/) i prijavite se ili kreirajte račun. + +2. Idite na **Postavke** > **Ključevi** i kliknite na **Dodaj API ključ** da generišete novi API ključ. + +3. Kopirajte API ključ za korištenje u OpenCode. + +4. Pokrenite naredbu `/connect` i potražite **Ollama Cloud**. + +```txt + /connect +``` + +5. Unesite svoj Ollama Cloud API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +6. **Važno**: Prije upotrebe modela oblaka u OpenCode, morate lokalno povući informacije o modelu: + +```bash + ollama pull gpt-oss:20b-cloud +``` + +7. Pokrenite naredbu `/models` da odaberete svoj model Ollama Cloud. + +```txt + /models +``` + +--- + +### OpenAI + +Preporučujemo da se prijavite za [ChatGPT Plus ili Pro](https://chatgpt.com/pricing). + +1. Nakon što ste se prijavili, pokrenite naredbu `/connect` i odaberite OpenAI. + +```txt + /connect +``` + +2. Ovdje možete odabrati opciju **ChatGPT Plus/Pro** i ona će otvoriti vaš pretraživač + i traži od vas da se autentifikujete. + +```txt + ┌ Select auth method + │ + │ ChatGPT Plus/Pro + │ Manually enter API Key + └ +``` + +3. Sada bi svi OpenAI modeli trebali biti dostupni kada koristite naredbu `/models`. + +```txt + /models +``` + +##### Korištenje API ključeva + +Ako već imate API ključ, možete odabrati **Ručno unesite API ključ** i zalijepite ga u svoj terminal. + +--- + +### OpenCode Zen + +OpenCode Zen je lista testiranih i verifikovanih modela koju je obezbedio OpenCode tim. [Saznajte više](/docs/zen). + +1. Prijavite se na **OpenCode Zen** i kliknite na **Kreiraj API ključ**. + +2. Pokrenite naredbu `/connect` i potražite **OpenCode Zen**. + +```txt + /connect +``` + +3. Unesite svoj OpenCode API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Qwen 3 Coder 480B_. + +```txt + /models +``` + +--- + +### OpenRouter + +1. Idite na [OpenRouter nadzornu ploču](https://openrouter.ai/settings/keys), kliknite na **Kreiraj API ključ** i kopirajte ključ. + +2. Pokrenite naredbu `/connect` i potražite OpenRouter. + +```txt + /connect +``` + +3. Unesite API ključ za provajdera. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Mnogi OpenRouter modeli su unapred učitani po defaultu, pokrenite naredbu `/models` da odaberete onaj koji želite. + +```txt + /models +``` + +Također možete dodati dodatne modele putem vaše opencode konfiguracije. + +```json title="opencode.json" {6} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "models": { + "somecoolnewmodel": {} + } + } + } +} +``` + +5. Također ih možete prilagoditi putem vaše opencode konfiguracije. Evo primjera navođenja provajdera + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "models": { + "moonshotai/kimi-k2": { + "options": { + "provider": { + "order": ["baseten"], + "allow_fallbacks": false + } + } + } + } + } + } +} +``` + +--- + +### SAP AI Core + +SAP AI Core omogućava pristup preko 40+ modela iz OpenAI, Anthropic, Google, Amazon, Meta, Mistral i AI21 putem objedinjene platforme. + +1. Idite na vaš [SAP BTP Cockpit](https://account.hana.ondemand.com/), idite na instancu usluge SAP AI Core i kreirajte servisni ključ. + + :::tip + Servisni ključ je JSON objekat koji sadrži `clientid`, `clientsecret`, `url` i `serviceurls.AI_API_URL`. Svoju AI Core instancu možete pronaći pod **Usluge** > **Instance i pretplate** u BTP kokpitu. + ::: + +2. Pokrenite naredbu `/connect` i potražite **SAP AI Core**. + +```txt + /connect +``` + +3. Unesite JSON svoj servisni ključ. + +```txt + ┌ Service key + │ + │ + └ enter +``` + +Ili postavite varijablu okruženja `AICORE_SERVICE_KEY`: + +```bash + AICORE_SERVICE_KEY='{"clientid":"...","clientsecret":"...","url":"...","serviceurls":{"AI_API_URL":"..."}}' opencode +``` + +Ili ga dodajte na svoj bash profil: + +```bash title="~/.bash_profile" + export AICORE_SERVICE_KEY='{"clientid":"...","clientsecret":"...","url":"...","serviceurls":{"AI_API_URL":"..."}}' +``` + +4. Opciono postavite ID implementacije i grupu resursa: + +```bash + AICORE_DEPLOYMENT_ID=your-deployment-id AICORE_RESOURCE_GROUP=your-resource-group opencode +``` + +:::note +Ove postavke su opcione i treba ih konfigurirati u skladu s vašim SAP AI Core postavkama. +::: + +5. Pokrenite naredbu `/models` da odaberete između 40+ dostupnih modela. + + ```txt + /models + ``` + +--- + +### STACKIT + +STACKIT AI Model Serving pruža potpuno upravljano suvereno hosting okruženje za AI modele, fokusirajući se na LLM-ove kao što su Llama, Mistral i Qwen, uz maksimalan suverenitet podataka na evropskoj infrastrukturi. + +1. Idite na [STACKIT Portal](https://portal.stackit.cloud), idite na **AI Model Serving** i kreirajte token za autentifikaciju za svoj projekat. + + :::tip + Potreban vam je STACKIT korisnički račun, korisnički nalog i projekat prije kreiranja tokena za autentifikaciju. + ::: + +2. Pokrenite naredbu `/connect` i potražite **STACKIT**. + + ```txt + /connect + ``` + +3. Unesite svoj STACKIT AI Model Serving token za autentifikaciju. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Pokrenite naredbu `/models` da odaberete dostupne modele kao što su _Qwen3-VL 235B_ ili _Llama 3.3 70B_. + + ```txt + /models + ``` + +--- + +### OVHcloud AI krajnje tačke + +1. Idite na [OVHcloud panel](https://ovh.com/manager). Idite do odjeljka `Public Cloud`, `AI & Machine Learning` > `AI Endpoints` i na kartici `API Keys` kliknite na **Kreiraj novi API ključ**. + +2. Pokrenite naredbu `/connect` i potražite **OVHcloud AI krajnje točke**. + +```txt + /connect +``` + +3. Unesite svoj OVHcloud AI Endpoints API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _gpt-oss-120b_. + +```txt + /models +``` + +--- + +### Scaleway + +Da biste koristili [Scaleway Generative APIs](https://www.scaleway.com/en/docs/generative-apis/) sa Opencodeom: + +1. Prijeđite na [Scaleway Console IAM postavke](https://console.scaleway.com/iam/api-keys) da generišete novi API ključ. + +2. Pokrenite naredbu `/connect` i potražite **Scaleway**. + +```txt + /connect +``` + +3. Unesite svoj Scaleway API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _devstral-2-123b-instruct-2512_ ili _gpt-oss-120b_. + +```txt + /models +``` + +--- + +### Together AI + +1. Idite na [Together AI console](https://api.together.ai), kreirajte nalog i kliknite na **Dodaj ključ**. + +2. Pokrenite naredbu `/connect` i potražite **Together AI**. + +```txt + /connect +``` + +3. Unesite svoj Together AI API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Kimi K2 Instruct_. + +```txt + /models +``` + +--- + +### Venice AI + +1. Idite na [Venice AI konzolu](https://venice.ai), kreirajte račun i generirajte API ključ. + +2. Pokrenite naredbu `/connect` i potražite **Venice AI**. + +```txt + /connect +``` + +3. Unesite svoj Venice AI API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Llama 3.3 70B_. + +```txt + /models +``` + +--- + +### Vercel AI Gateway + +Vercel AI Gateway vam omogućava da pristupite modelima iz OpenAI, Anthropic, Google, xAI i drugih putem objedinjene krajnje tačke. Modeli se nude po kataloškim cijenama bez maraka. + +1. Idite na [Vercel kontrolnu tablu](https://vercel.com/), idite na karticu **AI Gateway** i kliknite na **API ključevi** da kreirate novi API ključ. + +2. Pokrenite naredbu `/connect` i potražite **Vercel AI Gateway**. + +```txt + /connect +``` + +3. Unesite svoj Vercel AI Gateway API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model. + +```txt + /models +``` + +Također možete prilagoditi modele kroz svoju opencode konfiguraciju. Evo primjera specificiranja redoslijeda usmjeravanja dobavljača. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "vercel": { + "models": { + "anthropic/claude-sonnet-4": { + "options": { + "order": ["anthropic", "vertex"] + } + } + } + } + } +} +``` + +Neke korisne opcije rutiranja: + +| Opcija | Opis | +| ------------------- | ------------------------------------------------------------------ | +| `order` | Redoslijed dobavljača za pokušaj | +| `only` | Ograničiti na određene provajdere | +| `zeroDataRetention` | Koristite samo provajdere sa nultom politikom zadržavanja podataka | + +--- + +### xAI + +1. Prijeđite na [xAI konzolu](https://console.x.ai/), kreirajte račun i generirajte API ključ. + +2. Pokrenite naredbu `/connect` i potražite **xAI**. + +```txt + /connect +``` + +3. Unesite svoj xAI API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _Grok Beta_. + +```txt + /models +``` + +--- + +### Z.AI + +1. Idite na [Z.AI API konzolu](https://z.ai/manage-apikey/apikey-list), kreirajte nalog i kliknite na **Kreiraj novi API ključ**. + +2. Pokrenite naredbu `/connect` i potražite **Z.AI**. + +```txt + /connect +``` + +Ako ste pretplaćeni na **GLM plan kodiranja**, odaberite **Z.AI plan kodiranja**. + +3. Unesite svoj Z.AI API ključ. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Pokrenite naredbu `/models` da odaberete model kao što je _GLM-4.7_. + +```txt + /models +``` + +--- + +### ZenMux + +1. Idite na [ZenMux kontrolnu tablu](https://zenmux.ai/settings/keys), kliknite na **Kreiraj API ključ** i kopirajte ključ. + +2. Pokrenite naredbu `/connect` i potražite ZenMux. + +```txt + /connect +``` + +3. Unesite API ključ za provajdera. + +```txt + ┌ API key + │ + │ + └ enter +``` + +4. Mnogi ZenMux modeli su unaprijed učitani po defaultu, pokrenite naredbu `/models` da odaberete onaj koji želite. + +```txt + /models +``` + +Također možete dodati dodatne modele putem vaše opencode konfiguracije. + +```json title="opencode.json" {6} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "zenmux": { + "models": { + "somecoolnewmodel": {} + } + } + } +} +``` + +--- + +## Prilagođeni provajder + +Da biste dodali bilo kojeg **OpenAI-kompatibilnog** provajdera koji nije naveden u naredbi `/connect`: + +:::tip +Možete koristiti bilo kojeg OpenAI kompatibilnog provajdera s opencode-om. Većina modernih AI provajdera nudi API-je kompatibilne sa OpenAI. +::: + +1. Pokrenite naredbu `/connect` i pomaknite se prema dolje do **Ostalo**. + +```bash + $ /connect + + ┌ Add credential + │ + ◆ Select provider + │ ... + │ ● Other + └ +``` + +2. Unesite jedinstveni ID za provajdera. + +```bash + $ /connect + + ┌ Add credential + │ + ◇ Enter provider id + │ myprovider + └ +``` + +:::note +Odaberite ID koji se pamti, to ćete koristiti u svom konfiguracijskom fajlu. +::: + +3. Unesite svoj API ključ za provajdera. + +```bash + $ /connect + + ┌ Add credential + │ + ▲ This only stores a credential for myprovider - you will need to configure it in opencode.json, check the docs for examples. + │ + ◇ Enter your API key + │ sk-... + └ +``` + +4. Kreirajte ili ažurirajte svoju `opencode.json` datoteku u direktoriju projekta: + +```json title="opencode.json" ""myprovider"" {5-15} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "myprovider": { + "npm": "@ai-sdk/openai-compatible", + "name": "My AI ProviderDisplay Name", + "options": { + "baseURL": "https://api.myprovider.com/v1" + }, + "models": { + "my-model-name": { + "name": "My Model Display Name" + } + } + } + } +} +``` + +Evo opcija konfiguracije: + +- **npm**: AI SDK paket za korištenje, `@ai-sdk/openai-compatible` za OpenAI-kompatibilne provajdere +- **name**: Ime za prikaz u korisničkom sučelju. +- **modeli**: Dostupni modeli. +- **options.baseURL**: URL krajnje tačke API-ja. +- **options.apiKey**: Opciono postavite API ključ, ako ne koristite auth. +- **options.headers**: Opciono postavite prilagođena zaglavlja. + +Više o naprednim opcijama u primjeru ispod. + +5. Pokrenite naredbu `/models` i vaš prilagođeni provajder i modeli će se pojaviti na listi izbora. + +--- + +##### Primjer + +Evo primjera postavljanja opcija `apiKey`, `headers` i modela `limit`. + +```json title="opencode.json" {9,11,17-20} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "myprovider": { + "npm": "@ai-sdk/openai-compatible", + "name": "My AI ProviderDisplay Name", + "options": { + "baseURL": "https://api.myprovider.com/v1", + "apiKey": "{env:ANTHROPIC_API_KEY}", + "headers": { + "Authorization": "Bearer custom-token" + } + }, + "models": { + "my-model-name": { + "name": "My Model Display Name", + "limit": { + "context": 200000, + "output": 65536 + } + } + } + } + } +} +``` + +Detalji konfiguracije: + +- **apiKey**: Postavite pomoću sintakse varijable `env`, [saznajte više](/docs/config#env-vars). +- **zaglavlja**: Prilagođena zaglavlja se šalju sa svakim zahtjevom. +- **limit.context**: Maksimalni ulazni tokeni koje model prihvata. +- **limit.output**: Maksimalni tokeni koje model može generirati. + +Polja `limit` omogućavaju OpenCode da shvati koliko vam je konteksta ostalo. Standardni dobavljači ih automatski preuzimaju sa models.dev. + +--- + +## Rješavanje problema + +Ako imate problema s konfiguracijom provajdera, provjerite sljedeće: + +1. **Provjerite postavke autentifikacije**: Pokrenite `opencode auth list` da vidite da li su vjerodajnice + za provajdera se dodaju u vašu konfiguraciju. + + Ovo se ne odnosi na dobavljače kao što je Amazon Bedrock, koji se oslanjaju na varijable okruženja za svoju autentifikaciju. + +2. Za prilagođene provajdere, provjerite OpenCode konfiguraciju i: + - Uvjerite se da ID provajdera korišten u naredbi `/connect` odgovara ID-u u vašoj opencode konfiguraciji. + - Za provajdera se koristi pravi npm paket. Na primjer, koristite `@ai-sdk/cerebras` za Cerebras. A za sve ostale OpenAI kompatibilne provajdere, koristite `@ai-sdk/openai-compatible`. + - Provjerite da li se ispravna krajnja tačka API-ja koristi u polju `options.baseURL`. diff --git a/packages/web/src/content/docs/bs/rules.mdx b/packages/web/src/content/docs/bs/rules.mdx new file mode 100644 index 0000000000000000000000000000000000000000..3d186a045dd52aa729b602e2c28bf11b44f44e54 --- /dev/null +++ b/packages/web/src/content/docs/bs/rules.mdx @@ -0,0 +1,180 @@ +--- +title: Pravila +description: Postavite prilagodena uputstva za opencode. +--- + +Mozete dodati prilagodena uputstva za opencode tako sto kreirate `AGENTS.md` datoteku. Ovo je slicno pravilima u Cursor-u. Sadrzi uputstva koja se ubacuju u LLM kontekst da prilagode ponasanje za vas projekat. + +--- + +## Inicijalizacija + +Da kreirate novu `AGENTS.md` datoteku, pokrenite `/init` komandu u opencode. + +:::tip +Preporuceno je da `AGENTS.md` iz projekta commitujete u Git. +::: + +Ovo skenira projekat i njegov sadrzaj, razumije cemu projekat sluzi i generise `AGENTS.md`. Tako opencode bolje navigira kroz kod. + +Ako vec imate `AGENTS.md`, komanda ce pokusati da ga dopuni. + +--- + +## Primjer + +Datoteku mozete napraviti i rucno. Evo primjera sta mozete staviti u `AGENTS.md`. + +```markdown title="AGENTS.md" +# SST v3 Monorepo Project + +This is an SST v3 monorepo with TypeScript. The project uses bun workspaces for package management. + +## Project Structure + +- `packages/` - Contains all workspace packages (functions, core, web, etc.) +- `infra/` - Infrastructure definitions split by service (storage.ts, api.ts, web.ts) +- `sst.config.ts` - Main SST configuration with dynamic imports + +## Code Standards + +- Use TypeScript with strict mode enabled +- Shared code goes in `packages/core/` with proper exports configuration +- Functions go in `packages/functions/` +- Infrastructure should be split into logical files in `infra/` + +## Monorepo Conventions + +- Import shared modules using workspace names: `@my-app/core/example` +``` + +Ovdje dodajete uputstva specificna za projekat koja se dijele sa timom. + +--- + +## Tipovi + +opencode podrzava citanje `AGENTS.md` datoteke sa vise lokacija. Svaka lokacija ima drugu svrhu. + +### Projekat + +Stavite `AGENTS.md` u korijen projekta za pravila specificna za taj projekat. Primjenjuju se samo kada radite u tom direktoriju ili poddirektorijima. + +### Globalno + +Mozete imati i globalna pravila u `~/.config/opencode/AGENTS.md`. Ona se primjenjuju u svim opencode sesijama. + +Posto se ovo ne commituje u Git niti dijeli s timom, najbolje je da ovdje cuvate licna pravila koja LLM treba pratiti. + +### Kompatibilnost s Claude Code + +Za korisnike koji prelaze sa Claude Code, OpenCode podrzava i Claude konvencije datoteka kao rezervu: + +- **Pravila projekta**: `CLAUDE.md` u direktoriju projekta (koristi se ako ne postoji `AGENTS.md`) +- **Globalna pravila**: `~/.claude/CLAUDE.md` (koristi se ako ne postoji `~/.config/opencode/AGENTS.md`) +- **Skills**: `~/.claude/skills/` — pogledajte [Agent Skills](/docs/skills/) za detalje + +Da iskljucite kompatibilnost sa Claude Code, postavite jednu od ovih varijabli okruzenja: + +```bash +export OPENCODE_DISABLE_CLAUDE_CODE=1 # Disable all .claude support +export OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1 # Disable only ~/.claude/CLAUDE.md +export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills +``` + +--- + +## Prioritet + +Kada se opencode pokrene, trazi datoteke pravila ovim redoslijedom: + +1. **Lokalne datoteke** pretrazivanjem prema gore od trenutnog direktorija (`AGENTS.md`, `CLAUDE.md`) +2. **Globalna datoteka** na `~/.config/opencode/AGENTS.md` +3. **Claude Code datoteka** na `~/.claude/CLAUDE.md` (osim ako je iskljucena) + +Prva pronadena datoteka pobjeduje u svakoj kategoriji. Na primjer, ako imate i `AGENTS.md` i `CLAUDE.md`, koristi se samo `AGENTS.md`. Isto tako, `~/.config/opencode/AGENTS.md` ima prednost nad `~/.claude/CLAUDE.md`. + +--- + +## Prilagođena uputstva + +Mozete navesti prilagodene datoteke uputstava u `opencode.json` ili globalnom `~/.config/opencode/opencode.json`. Tako vi i tim ponovo koristite postojeca pravila bez dupliranja u AGENTS.md. + +Primjer: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md"] +} +``` + +Mozete koristiti i udaljene URL-ove za ucitavanje uputstava sa weba. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["https://raw.githubusercontent.com/my-org/shared-rules/main/style.md"] +} +``` + +Udaljena uputstva se preuzimaju uz timeout od 5 sekundi. + +Sve datoteke uputstava se kombinuju sa vasim `AGENTS.md` datotekama. + +--- + +## Referenciranje eksternih datoteka + +Iako opencode ne parsira automatski reference datoteka u `AGENTS.md`, slicno ponasanje mozete dobiti na dva nacina: + +### Korištenje opencode.json + +Preporuceni pristup je da koristite `instructions` polje u `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["docs/development-standards.md", "test/testing-guidelines.md", "packages/*/AGENTS.md"] +} +``` + +### Ručna uputstva u AGENTS.md + +Mozete nauciti opencode da cita eksterne datoteke tako sto cete dati eksplicitna uputstva u `AGENTS.md`. Evo prakticnog primjera: + +```markdown title="AGENTS.md" +# TypeScript Project Rules + +## External File Loading + +CRITICAL: When you encounter a file reference (e.g., @rules/general.md), use your Read tool to load it on a need-to-know basis. They're relevant to the SPECIFIC task at hand. + +Instructions: + +- Do NOT preemptively load all references - use lazy loading based on actual need +- When loaded, treat content as mandatory instructions that override defaults +- Follow references recursively when needed + +## Development Guidelines + +For TypeScript code style and best practices: @docs/typescript-guidelines.md +For React component architecture and hooks patterns: @docs/react-patterns.md +For REST API design and error handling: @docs/api-standards.md +For testing strategies and coverage requirements: @test/testing-guidelines.md + +## General Guidelines + +Read the following file immediately as it's relevant to all workflows: @rules/general-guidelines.md. +``` + +Ovaj pristup vam omogucava da: + +- Kreirate modularne datoteke pravila koje se mogu ponovo koristiti +- Dijelite pravila izmedu projekata kroz symlinkove ili git submodule +- Drzite AGENTS.md kratkim dok upucujete na detaljne smjernice +- Osigurate da opencode ucitava datoteke samo kad su potrebne za konkretan zadatak + +:::tip +Za monorepo projekte ili projekte sa zajednickim standardima, odrzivije je koristiti `opencode.json` sa glob obrascima (npr. `packages/*/AGENTS.md`) nego rucna uputstva. +::: diff --git a/packages/web/src/content/docs/bs/sdk.mdx b/packages/web/src/content/docs/bs/sdk.mdx new file mode 100644 index 0000000000000000000000000000000000000000..cc9c2b3bf4334c5b76f15208464ad9f62f8a3edd --- /dev/null +++ b/packages/web/src/content/docs/bs/sdk.mdx @@ -0,0 +1,463 @@ +--- +title: SDK +description: Type-safe JS klijent za opencode server. +--- + +import config from "../../../../config.mjs" +export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` + +opencode JS/TS SDK pruza type-safe klijent za interakciju sa serverom. +Koristite ga za izradu integracija i programsko upravljanje opencode-om. + +[Saznajte vise](/docs/server) kako server radi. Za primjere pogledajte [projects](/docs/ecosystem#projects) koje je napravila zajednica. + +--- + +## Instalacija + +Instalirajte SDK sa npm-a: + +```bash +npm install @opencode-ai/sdk +``` + +--- + +## Kreiranje klijenta + +Kreirajte instancu opencode: + +```javascript +import { createOpencode } from "@opencode-ai/sdk" + +const { client } = await createOpencode() +``` + +Ovo pokrece i server i klijent + +#### Opcije + +| Opcija | Tip | Opis | Default | +| ---------- | ------------- | ----------------------------- | ----------- | +| `hostname` | `string` | Hostname servera | `127.0.0.1` | +| `port` | `number` | Port servera | `4096` | +| `signal` | `AbortSignal` | Abort signal za otkazivanje | `undefined` | +| `timeout` | `number` | Timeout u ms za start servera | `5000` | +| `config` | `Config` | Konfiguracijski objekat | `{}` | + +--- + +## Konfiguracija + +Mozete proslijediti konfiguracijski objekat za prilagodavanje ponasanja. Instanca i dalje ucitava `opencode.json`, ali konfiguraciju mozete nadjacati ili dodati inline: + +```javascript +import { createOpencode } from "@opencode-ai/sdk" + +const opencode = await createOpencode({ + hostname: "127.0.0.1", + port: 4096, + config: { + model: "anthropic/claude-3-5-sonnet-20241022", + }, +}) + +console.log(`Server running at ${opencode.server.url}`) + +opencode.server.close() +``` + +## Samo klijent + +Ako vec imate pokrenutu opencode instancu, mozete napraviti klijentsku instancu i povezati se na nju: + +```javascript +import { createOpencodeClient } from "@opencode-ai/sdk" + +const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", +}) +``` + +#### Opcije + +| Opcija | Tip | Opis | Default | +| --------------- | ---------- | --------------------------------- | ----------------------- | +| `baseUrl` | `string` | URL servera | `http://localhost:4096` | +| `fetch` | `function` | Prilagodena fetch implementacija | `globalThis.fetch` | +| `parseAs` | `string` | Metoda parsiranja odgovora | `auto` | +| `responseStyle` | `string` | Stil povrata: `data` ili `fields` | `fields` | +| `throwOnError` | `boolean` | Baci greske umjesto povrata | `false` | + +--- + +## Tipovi + +SDK ukljucuje TypeScript definicije za sve API tipove. Uvezite ih direktno: + +```typescript +import type { Session, Message, Part } from "@opencode-ai/sdk" +``` + +Svi tipovi su generisani iz OpenAPI specifikacije servera i dostupni u types datoteci. + +--- + +## Greške + +SDK moze baciti greske koje mozete uhvatiti i obraditi: + +```typescript +try { + await client.session.get({ path: { id: "invalid-id" } }) +} catch (error) { + console.error("Failed to get session:", (error as Error).message) +} +``` + +--- + +## Strukturirani izlaz + +Možete zatražiti strukturirani JSON izlaz od modela specificiranjem `format` sa JSON šemom. Model će koristiti `StructuredOutput` alat da vrati validirani JSON koji odgovara vašoj šemi. + +### Osnovna upotreba + +```typescript +const result = await client.session.prompt({ + path: { id: sessionId }, + body: { + parts: [{ type: "text", text: "Research Anthropic and provide company info" }], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + company: { type: "string", description: "Company name" }, + founded: { type: "number", description: "Year founded" }, + products: { + type: "array", + items: { type: "string" }, + description: "Main products", + }, + }, + required: ["company", "founded"], + }, + }, + }, +}) + +// Access the structured output +console.log(result.data.info.structured_output) +// { company: "Anthropic", founded: 2021, products: ["Claude", "Claude API"] } +``` + +### Tipovi formata izlaza + +| Tip | Opis | +| ------------- | ------------------------------------------------------------------- | +| `text` | Default. Standardni tekstualni odgovor (nema strukturiranog izlaza) | +| `json_schema` | Vraća validirani JSON koji odgovara pruženoj šemi | + +### Format JSON šeme + +Kada koristite `type: 'json_schema'`, navedite: + +| Polje | Tip | Opis | +| ------------ | --------------- | ----------------------------------------------------------- | +| `type` | `'json_schema'` | Obavezno. Određuje JSON schema način rada | +| `schema` | `object` | Obavezno. JSON Schema objekt koji definira strukturu izlaza | +| `retryCount` | `number` | Opcionalno. Broj ponovnih pokušaja validacije (default: 2) | + +### Rukovanje greškama + +Ako model ne uspije proizvesti validan strukturirani izlaz nakon svih ponovnih pokušaja, odgovor će uključivati `StructuredOutputError`: + +```typescript +if (result.data.info.error?.name === "StructuredOutputError") { + console.error("Failed to produce structured output:", result.data.info.error.message) + console.error("Attempts:", result.data.info.error.retries) +} +``` + +### Najbolje prakse + +1. **Navedite jasne opise** u svojstvima vaše šeme kako biste pomogli modelu da razumije koje podatke treba izdvojiti +2. **Koristite `required`** da odredite koja polja moraju biti prisutna +3. **Držite šeme fokusiranim** - složene ugniježđene šeme modelu mogu biti teže za ispravno popunjavanje +4. **Postavite odgovarajući `retryCount`** - povećajte za složene šeme, smanjite za jednostavne + +--- + +## API-ji + +SDK izlaže sve server API-je kroz type-safe klijent. + +--- + +### Global + +| Metoda | Opis | Odgovor | +| ----------------- | --------------------------- | ------------------------------------ | +| `global.health()` | Provjera zdravlja i verzije | `{ healthy: true, version: string }` | + +--- + +#### Primjeri + +```javascript +const health = await client.global.health() +console.log(health.data.version) +``` + +--- + +### App + +| Metoda | Opis | Odgovor | +| -------------- | ----------------------- | ------------------------------------------- | +| `app.log()` | Upis log zapisa | `boolean` | +| `app.agents()` | Lista dostupnih agenata | Agent[] | + +--- + +#### Primjeri + +```javascript +// Write a log entry +await client.app.log({ + body: { + service: "my-app", + level: "info", + message: "Operation completed", + }, +}) + +// List available agents +const agents = await client.app.agents() +``` + +--- + +### Project + +| Metoda | Opis | Odgovor | +| ------------------- | -------------------- | --------------------------------------------- | +| `project.list()` | Lista svih projekata | Project[] | +| `project.current()` | Trenutni projekat | Project | + +--- + +#### Primjeri + +```javascript +// List all projects +const projects = await client.project.list() + +// Get current project +const currentProject = await client.project.current() +``` + +--- + +### Path + +| Metoda | Opis | Odgovor | +| ------------ | ---------------- | ---------------------------------------- | +| `path.get()` | Trenutna putanja | Path | + +--- + +#### Primjeri + +```javascript +// Get current path information +const pathInfo = await client.path.get() +``` + +--- + +### Konfiguracija + +| Metoda | Opis | Odgovor | +| -------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `config.get()` | Info o konfiguraciji | Config | +| `config.providers()` | Lista provajdera i default modela | `{ providers: `Provider[]`, default: { [key: string]: string } }` | + +--- + +#### Primjeri + +```javascript +const config = await client.config.get() + +const { providers, default: defaults } = await client.config.providers() +``` + +--- + +### Sesije + +| Metoda | Opis | Napomene | +| ---------------------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session.list()` | List sessions | Returns Session[] | +| `session.get({ path })` | Get session | Returns Session | +| `session.children({ path })` | List child sessions | Returns Session[] | +| `session.create({ body })` | Create session | Returns Session | +| `session.delete({ path })` | Delete session | Returns `boolean` | +| `session.update({ path, body })` | Update session properties | Returns Session | +| `session.init({ path, body })` | Analyze app and create `AGENTS.md` | Returns `boolean` | +| `session.abort({ path })` | Abort a running session | Returns `boolean` | +| `session.share({ path })` | Share session | Returns Session | +| `session.unshare({ path })` | Unshare session | Returns Session | +| `session.summarize({ path, body })` | Summarize session | Returns `boolean` | +| `session.messages({ path })` | List messages in a session | Returns `{ info: `Message`, parts: `Part[]`}[]` | +| `session.message({ path })` | Get message details | Returns `{ info: `Message`, parts: `Part[]`}` | +| `session.prompt({ path, body })` | Send prompt message | `body.noReply: true` returns UserMessage (context only). Default returns AssistantMessage with AI response. Supports `body.outputFormat` for [structured output](#strukturirani-izlaz) | +| `session.command({ path, body })` | Send command to session | Returns `{ info: `AssistantMessage`, parts: `Part[]`}` | +| `session.shell({ path, body })` | Run a shell command | Returns AssistantMessage | +| `session.revert({ path, body })` | Revert a message | Returns Session | +| `session.unrevert({ path })` | Restore reverted messages | Returns Session | +| `postSessionByIdPermissionsByPermissionId({ path, body })` | Respond to a permission request | Returns `boolean` | + +--- + +#### Primjeri + +```javascript +// Create and manage sessions +const session = await client.session.create({ + body: { title: "My session" }, +}) + +const sessions = await client.session.list() + +// Send a prompt message +const result = await client.session.prompt({ + path: { id: session.id }, + body: { + model: { providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" }, + parts: [{ type: "text", text: "Hello!" }], + }, +}) + +// Inject context without triggering AI response (useful for plugins) +await client.session.prompt({ + path: { id: session.id }, + body: { + noReply: true, + parts: [{ type: "text", text: "You are a helpful assistant." }], + }, +}) +``` + +--- + +### Datoteke + +| Metoda | Opis | Odgovor | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- | +| `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | +| `find.files({ query })` | Find files and directories by name | `string[]` (paths) | +| `find.symbols({ query })` | Find workspace symbols | Symbol[] | +| `file.read({ query })` | Read a file | `{ type: "raw" \| "patch", content: string }` | +| `file.status({ query? })` | Get status for tracked files | File[] | + +`find.files` supports a few optional query fields: + +- `type`: `"file"` or `"directory"` +- `directory`: override the project root for the search +- `limit`: max results (1–200) + +--- + +#### Primjeri + +```javascript +// Search and read files +const textResults = await client.find.text({ + query: { pattern: "function.*opencode" }, +}) + +const files = await client.find.files({ + query: { query: "*.ts", type: "file" }, +}) + +const directories = await client.find.files({ + query: { query: "packages", type: "directory", limit: 20 }, +}) + +const content = await client.file.read({ + query: { path: "src/index.ts" }, +}) +``` + +--- + +### TUI + +| Metoda | Opis | Odgovor | +| ------------------------------ | ------------------------- | --------- | +| `tui.appendPrompt({ body })` | Append text to the prompt | `boolean` | +| `tui.openHelp()` | Open the help dialog | `boolean` | +| `tui.openSessions()` | Open the session selector | `boolean` | +| `tui.openThemes()` | Open the theme selector | `boolean` | +| `tui.openModels()` | Open the model selector | `boolean` | +| `tui.submitPrompt()` | Submit the current prompt | `boolean` | +| `tui.clearPrompt()` | Clear the prompt | `boolean` | +| `tui.executeCommand({ body })` | Execute a command | `boolean` | +| `tui.showToast({ body })` | Show toast notification | `boolean` | + +--- + +#### Primjeri + +```javascript +// Control TUI interface +await client.tui.appendPrompt({ + body: { text: "Add this to prompt" }, +}) + +await client.tui.showToast({ + body: { message: "Task completed", variant: "success" }, +}) +``` + +--- + +### Auth + +| Metoda | Opis | Odgovor | +| ------------------- | ------------------------------ | --------- | +| `auth.set({ ... })` | Set authentication credentials | `boolean` | + +--- + +#### Primjeri + +```javascript +await client.auth.set({ + path: { id: "anthropic" }, + body: { type: "api", key: "your-api-key" }, +}) +``` + +--- + +### Događaji + +| Metoda | Opis | Odgovor | +| ------------------- | ------------------------- | ------------------------- | +| `event.subscribe()` | Server-sent events stream | Server-sent events stream | + +--- + +#### Primjeri + +```javascript +// Listen to real-time events +const events = await client.event.subscribe() +for await (const event of events.stream) { + console.log("Event:", event.type, event.properties) +} +``` diff --git a/packages/web/src/content/docs/bs/server.mdx b/packages/web/src/content/docs/bs/server.mdx new file mode 100644 index 0000000000000000000000000000000000000000..5237873d5f77bbace67b27403b23fad3cedca052 --- /dev/null +++ b/packages/web/src/content/docs/bs/server.mdx @@ -0,0 +1,284 @@ +--- +title: Server +description: Komunicirajte s opencode serverom preko HTTP-a. +--- + +import config from "../../../../config.mjs" +export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` + +Komanda `opencode serve` pokrece headless HTTP server koji izlaže OpenAPI endpoint koji opencode klijent moze koristiti. + +--- + +### Korištenje + +```bash +opencode serve [--port ] [--hostname ] [--cors ] +``` + +#### Opcije + +| Zastava | Opis | Default | +| --------------- | ----------------------------------- | ---------------- | +| `--port` | Port na kojem slusa | `4096` | +| `--hostname` | Hostname na kojem slusa | `127.0.0.1` | +| `--mdns` | Ukljuci mDNS otkrivanje | `false` | +| `--mdns-domain` | Prilagodeni domen za mDNS servis | `opencode.local` | +| `--cors` | Dodatni browser origin-i koje dozv. | `[]` | + +`--cors` mozete navesti vise puta: + +```bash +opencode serve --cors http://localhost:5173 --cors https://app.example.com +``` + +--- + +### Autentifikacija + +Postavite `OPENCODE_SERVER_PASSWORD` da zastitite server HTTP basic auth mehanizmom. Korisnicko ime je po defaultu `opencode`, ili postavite `OPENCODE_SERVER_USERNAME` za nadjacavanje. Ovo vazi i za `opencode serve` i za `opencode web`. + +```bash +OPENCODE_SERVER_PASSWORD=your-password opencode serve +``` + +--- + +### Kako radi + +Kada pokrenete `opencode`, pokrecu se TUI i server. TUI je klijent koji komunicira sa serverom. Server izlaže OpenAPI 3.1 spec endpoint koji se koristi i za generisanje [SDK-a](/docs/sdk). + +:::tip +Koristite opencode server za programsku interakciju sa opencode-om. +::: + +Ova arhitektura omogucava opencode podrsku za vise klijenata i programsku interakciju. + +Mozete pokrenuti `opencode serve` da startate standalone server. Ako je opencode TUI vec pokrenut, `opencode serve` ce pokrenuti novi server. + +--- + +#### Povezivanje na postojeći server + +Kada pokrenete TUI, port i hostname se nasumicno dodijele. Umjesto toga, mozete zadati `--hostname` i `--port` [zastave](/docs/cli), pa se povezati na taj server. + +Endpoint [`/tui`](#tui) mozete koristiti za upravljanje TUI-jem kroz server. Na primjer, mozete unaprijed popuniti ili pokrenuti prompt. Ovaj setup koriste OpenCode [IDE](/docs/ide) pluginovi. + +--- + +## Specifikacija + +Server objavljuje OpenAPI 3.1 specifikaciju koju mozete vidjeti na: + +``` +http://:/doc +``` + +Na primjer, `http://localhost:4096/doc`. Koristite specifikaciju da generisete klijente ili pregledate tipove zahtjeva i odgovora. Mozete je otvoriti i u Swagger exploreru. + +--- + +## API-ji + +opencode server izlaže sljedece API-je. + +--- + +### Globalno + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ---------------- | ----------------------------------- | ------------------------------------ | +| `GET` | `/global/health` | Dohvati zdravlje i verziju servera | `{ healthy: true, version: string }` | +| `GET` | `/global/event` | Dohvati globalne događaje (SSE tok) | Event stream | + +--- + +### Projekt + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ------------------ | ------------------------ | --------------------------------------------- | +| `GET` | `/project` | Izlistaj sve projekte | Project[] | +| `GET` | `/project/current` | Dohvati trenutni projekt | Project | + +--- + +### Putanja i VCS + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ------- | ------------------------------------------- | ------------------------------------------- | +| `GET` | `/path` | Dohvati trenutnu putanju | Path | +| `GET` | `/vcs` | Dohvati VCS informacije za trenutni projekt | VcsInfo | + +--- + +### Instanca + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ------------------- | ----------------------- | --------- | +| `POST` | `/instance/dispose` | Ugasi trenutnu instancu | `boolean` | + +--- + +### Konfiguracija + +| Metoda | Putanja | Opis | Odgovor | +| ------- | ------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------- | +| `GET` | `/config` | Dohvati informacije o konfiguraciji | Config | +| `PATCH` | `/config` | Ažuriraj konfiguraciju | Config | +| `GET` | `/config/providers` | Izlistaj provajdere i zadane modele | `{ providers: `Provider[]`, default: { [key: string]: string } }` | + +--- + +### Provajder + +| Metoda | Putanja | Opis | Odgovor | +| ------ | -------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------- | +| `GET` | `/provider` | Izlistaj sve provajdere | `{ all: `Provider[]`, default: {...}, connected: string[] }` | +| `GET` | `/provider/auth` | Dohvati metode autentifikacije provajdera | `{ [providerID: string]: `ProviderAuthMethod[]` }` | +| `POST` | `/provider/{id}/oauth/authorize` | Autoriziraj provajdera koristeći OAuth | ProviderAuthAuthorization | +| `POST` | `/provider/{id}/oauth/callback` | Obradi OAuth povratni poziv za provajdera | `boolean` | + +--- + +### Sesije + +| Metoda | Putanja | Opis | Napomene | +| -------- | ---------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------- | +| `GET` | `/session` | Izlistaj sve sesije | Returns Session[] | +| `POST` | `/session` | Kreiraj novu sesiju | body: `{ parentID?, title? }`, returns Session | +| `GET` | `/session/status` | Dohvati status sesije za sve sesije | Returns `{ [sessionID: string]: `SessionStatus` }` | +| `GET` | `/session/:id` | Dohvati detalje sesije | Returns Session | +| `DELETE` | `/session/:id` | Obriši sesiju i sve njene podatke | Returns `boolean` | +| `PATCH` | `/session/:id` | Ažuriraj svojstva sesije | body: `{ title? }`, returns Session | +| `GET` | `/session/:id/children` | Dohvati pod-sesije sesije | Returns Session[] | +| `GET` | `/session/:id/todo` | Dohvati listu zadataka za sesiju | Returns Todo[] | +| `POST` | `/session/:id/init` | Analiziraj aplikaciju i kreiraj `AGENTS.md` | body: `{ messageID, providerID, modelID }`, returns `boolean` | +| `POST` | `/session/:id/fork` | Granaj postojeću sesiju na poruci | body: `{ messageID? }`, returns Session | +| `POST` | `/session/:id/abort` | Prekini sesiju u toku | Returns `boolean` | +| `POST` | `/session/:id/share` | Podijeli sesiju | Returns Session | +| `DELETE` | `/session/:id/share` | Prestani dijeliti sesiju | Returns Session | +| `GET` | `/session/:id/diff` | Dohvati razlike za ovu sesiju | query: `messageID?`, returns FileDiff[] | +| `POST` | `/session/:id/summarize` | Rezimiraj sesiju | body: `{ providerID, modelID }`, returns `boolean` | +| `POST` | `/session/:id/revert` | Vrati poruku | body: `{ messageID, partID? }`, returns `boolean` | +| `POST` | `/session/:id/unrevert` | Vrati sve vraćene poruke | Returns `boolean` | +| `POST` | `/session/:id/permissions/:permissionID` | Odgovori na zahtjev za dozvolu | body: `{ response, remember? }`, returns `boolean` | + +--- + +### Poruke + +| Metoda | Putanja | Opis | Napomene | +| ------ | --------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/session/:id/message` | Izlistaj poruke u sesiji | query: `limit?`, returns `{ info: `Message`, parts: `Part[]`}[]` | +| `POST` | `/session/:id/message` | Pošalji poruku i čekaj odgovor | body: `{ messageID?, model?, agent?, noReply?, system?, tools?, parts }`, returns `{ info: `Message`, parts: `Part[]`}` | +| `GET` | `/session/:id/message/:messageID` | Dohvati detalje poruke | Returns `{ info: `Message`, parts: `Part[]`}` | +| `POST` | `/session/:id/prompt_async` | Pošalji poruku asinkrono (bez čekanja) | body: same as `/session/:id/message`, returns `204 No Content` | +| `POST` | `/session/:id/command` | Izvrši slash naredbu | body: `{ messageID?, agent?, model?, command, arguments }`, returns `{ info: `Message`, parts: `Part[]`}` | +| `POST` | `/session/:id/shell` | Pokreni shell naredbu | body: `{ agent, model?, command }`, returns `{ info: `Message`, parts: `Part[]`}` | + +--- + +### Naredbe + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ---------- | -------------------- | --------------------------------------------- | +| `GET` | `/command` | Izlistaj sve naredbe | Command[] | + +--- + +### Datoteke + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------- | +| `GET` | `/find?pattern=` | Traži tekst u datotekama | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | +| `GET` | `/find/file?query=` | Pronađi datoteke i direktorije po imenu | `string[]` (paths) | +| `GET` | `/find/symbol?query=` | Pronađi simbole radnog prostora | Symbol[] | +| `GET` | `/file?path=` | Izlistaj datoteke i direktorije | FileNode[] | +| `GET` | `/file/content?path=

` | Pročitaj datoteku | FileContent | +| `GET` | `/file/status` | Dohvati status za praćene datoteke | File[] | + +#### `/find/file` parametri upita + +- `query` (obavezno) — niz za pretragu (fuzzy podudaranje) +- `type` (opcionalno) — ograniči rezultate na `"file"` ili `"directory"` +- `directory` (opcionalno) — nadjačaj korijen projekta za pretragu +- `limit` (opcionalno) — maksimalni rezultati (1–200) +- `dirs` (opcionalno) — zastarjela zastavica (`"false"` vraća samo datoteke) + +--- + +### Alati (Eksperimentalno) + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ------------------------------------------- | -------------------------------------- | -------------------------------------------- | +| `GET` | `/experimental/tool/ids` | Izlistaj sve ID-ove alata | ToolIDs | +| `GET` | `/experimental/tool?provider=

&model=` | Izlistaj alate sa JSON šemama za model | ToolList | + +--- + +### LSP, Formateri & MCP + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ------------ | -------------------------- | -------------------------------------------------------- | +| `GET` | `/lsp` | Dohvati status LSP servera | LSPStatus[] | +| `GET` | `/formatter` | Dohvati status formatera | FormatterStatus[] | +| `GET` | `/mcp` | Dohvati status MCP servera | `{ [name: string]: `MCPStatus` }` | +| `POST` | `/mcp` | Dodaj MCP server dinamički | body: `{ name, config }`, returns MCP status object | + +--- + +### Agenti + +| Metoda | Putanja | Opis | Odgovor | +| ------ | -------- | ---------------------------- | ------------------------------------------- | +| `GET` | `/agent` | Izlistaj sve dostupne agente | Agent[] | + +--- + +### Bilježenje + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ------- | ------------------------------------------------------------------- | --------- | +| `POST` | `/log` | Upiši zapis dnevnika. Tijelo: `{ service, level, message, extra? }` | `boolean` | + +--- + +### TUI + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ----------------------- | -------------------------------------------------------- | ---------------------- | +| `POST` | `/tui/append-prompt` | Dodaj tekst na prompt | `boolean` | +| `POST` | `/tui/open-help` | Otvori dijalog za pomoć | `boolean` | +| `POST` | `/tui/open-sessions` | Otvori selektor sesija | `boolean` | +| `POST` | `/tui/open-themes` | Otvori selektor tema | `boolean` | +| `POST` | `/tui/open-models` | Otvori selektor modela | `boolean` | +| `POST` | `/tui/submit-prompt` | Pošalji trenutni prompt | `boolean` | +| `POST` | `/tui/clear-prompt` | Očisti prompt | `boolean` | +| `POST` | `/tui/execute-command` | Izvrši naredbu (`{ command }`) | `boolean` | +| `POST` | `/tui/show-toast` | Prikaži toast obavijest (`{ title?, message, variant }`) | `boolean` | +| `GET` | `/tui/control/next` | Čekaj sljedeći kontrolni zahtjev | Control request object | +| `POST` | `/tui/control/response` | Odgovori na kontrolni zahtjev (`{ body }`) | `boolean` | + +--- + +### Autentifikacija + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ----------- | ------------------------------------------------------------------------------- | --------- | +| `PUT` | `/auth/:id` | Postavi autentifikacijske vjerodajnice. Tijelo mora odgovarati shemi provajdera | `boolean` | + +--- + +### Događaji + +| Metoda | Putanja | Opis | Odgovor | +| ------ | -------- | -------------------------------------------------------------------------------------- | ------------------------- | +| `GET` | `/event` | Tok događaja koje šalje server. Prvi događaj je `server.connected`, zatim bus događaji | Server-sent events stream | + +--- + +### Dokumentacija + +| Metoda | Putanja | Opis | Odgovor | +| ------ | ------- | ------------------------- | --------------------------------------- | +| `GET` | `/doc` | OpenAPI 3.1 specifikacija | HTML stranica sa OpenAPI specifikacijom | diff --git a/packages/web/src/content/docs/bs/share.mdx b/packages/web/src/content/docs/bs/share.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b0760ee0c13842d43e8893a3268ef1c92e26e795 --- /dev/null +++ b/packages/web/src/content/docs/bs/share.mdx @@ -0,0 +1,127 @@ +--- +title: Dijeljenje +description: Dijelite OpenCode razgovore javnim linkovima. +--- + +OpenCode opcija dijeljenja vam omogucava da kreirate javne linkove za razgovore. Tako lakse saradujete s timom ili trazite pomoc od drugih. + +:::note +Dijeljeni razgovori su javno dostupni svakome ko ima link. +::: + +--- + +## Kako radi + +Kada podijelite razgovor, OpenCode: + +1. Kreira jedinstveni javni URL za vasu sesiju +2. Sinhronizuje historiju razgovora na nase servere +3. Cini razgovor dostupnim preko linka za dijeljenje — `opncd.ai/s/` + +--- + +## Dijeljenje + +OpenCode podrzava tri nacina dijeljenja koji odreduju kako se razgovori dijele: + +--- + +### Ručno (zadano) + +Po defaultu, OpenCode koristi rucni nacin dijeljenja. Sesije se ne dijele automatski, ali ih mozete rucno podijeliti komandom `/share`: + +``` +/share +``` + +Ovo ce generisati jedinstveni URL i kopirati ga u clipboard. + +Da eksplicitno postavite rucni nacin u [config datoteci](/docs/config): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "manual" +} +``` + +--- + +### Automatsko dijeljenje + +Mozete ukljuciti automatsko dijeljenje za sve nove razgovore tako sto `share` postavite na `"auto"` u [config datoteci](/docs/config): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "auto" +} +``` + +Kada je auto-share ukljucen, svaki novi razgovor se automatski dijeli i kreira se link. + +--- + +### Onemogućeno + +Dijeljenje mozete potpuno iskljuciti tako sto `share` postavite na `"disabled"` u [config datoteci](/docs/config): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled" +} +``` + +Da ovo vazi za cijeli tim u odredenom projektu, dodajte postavku u projektni `opencode.json` i commitujte u Git. + +--- + +## Uklanjanje dijeljenja + +Da prestanete dijeliti razgovor i uklonite javni pristup: + +``` +/unshare +``` + +Ovo uklanja link za dijeljenje i brise podatke povezane s razgovorom. + +--- + +## Privatnost + +Imajte na umu nekoliko stvari prije dijeljenja razgovora. + +--- + +### Čuvanje podataka + +Dijeljeni razgovori ostaju dostupni dok ih eksplicitno ne uklonite iz dijeljenja. Ovo ukljucuje: + +- Kompletnu historiju razgovora +- Sve poruke i odgovore +- Metapodatke sesije + +--- + +### Preporuke + +- Dijelite samo razgovore koji ne sadrze osjetljive informacije. +- Pregledajte sadrzaj razgovora prije dijeljenja. +- Uklonite dijeljenje kad saradnja zavrsi. +- Izbjegavajte dijeljenje razgovora s vlasnickim kodom ili povjerljivim podacima. +- Za osjetljive projekte potpuno iskljucite dijeljenje. + +--- + +## Za enterprise + +Za enterprise okruzenja, opcija dijeljenja moze biti: + +- **Iskljucena** u potpunosti radi sigurnosne uskladenosti +- **Ogranicena** samo na korisnike autentifikovane kroz SSO +- **Self-hosted** na vasoj infrastrukturi + +[Saznajte vise](/docs/enterprise) o koristenju opencode u organizaciji. diff --git a/packages/web/src/content/docs/bs/skills.mdx b/packages/web/src/content/docs/bs/skills.mdx new file mode 100644 index 0000000000000000000000000000000000000000..655dc9d107bddfc747a8d27490f0005d835d56d0 --- /dev/null +++ b/packages/web/src/content/docs/bs/skills.mdx @@ -0,0 +1,222 @@ +--- +title: Vještine agenata +description: "Definisite ponasanje koje se moze ponovo koristiti" +--- + +Agent skills omogucavaju OpenCode da pronade uputstva koja se mogu ponovo koristiti iz repozitorija ili home direktorija. +Skills se ucitavaju po potrebi kroz ugradeni `skill` alat - agenti vide dostupne skills i ucitavaju puni sadrzaj kad zatreba. + +--- + +## Postavite datoteke + +Kreirajte jedan folder po nazivu skill-a i stavite `SKILL.md` unutar njega. +OpenCode pretrazuje ove lokacije: + +- Konfiguracija projekta: `.opencode/skills//SKILL.md` +- Globalna konfiguracija: `~/.config/opencode/skills//SKILL.md` +- Claude kompatibilno u projektu: `.claude/skills//SKILL.md` +- Globalno Claude kompatibilno: `~/.claude/skills//SKILL.md` +- Agent kompatibilno u projektu: `.agents/skills//SKILL.md` +- Globalno agent kompatibilno: `~/.agents/skills//SKILL.md` + +--- + +## Razumijte otkrivanje + +Za projektne lokalne putanje, OpenCode ide prema gore od trenutnog radnog direktorija dok ne dode do git worktree-ja. +Usput ucitava sve odgovarajuce `skills/*/SKILL.md` u `.opencode/` i odgovarajuce `.claude/skills/*/SKILL.md` ili `.agents/skills/*/SKILL.md`. + +Globalne definicije se takoder ucitavaju iz `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md` i `~/.agents/skills/*/SKILL.md`. + +--- + +## Pisanje frontmatter-a + +Svaki `SKILL.md` mora poceti YAML frontmatter-om. +Prepoznaju se samo ova polja: + +- `name` (obavezno) +- `description` (obavezno) +- `license` (opcionalno) +- `compatibility` (opcionalno) +- `metadata` (opcionalno, mapa string->string) + +Nepoznata frontmatter polja se ignorisu. + +--- + +## Validirajte nazive + +`name` mora: + +- Imati 1-64 karaktera +- Biti malim slovima i brojevima sa jednim crticama kao razdvajacima +- Ne pocinjati ni zavrsavati sa `-` +- Ne sadrzavati uzastopno `--` +- Odgovarati nazivu direktorija koji sadrzi `SKILL.md` + +Ekvivalentni regex: + +```text +^[a-z0-9]+(-[a-z0-9]+)*$ +``` + +--- + +## Pravila dužine + +`description` mora imati 1-1024 karaktera. +Neka bude dovoljno precizan da agent moze pravilno odabrati. + +--- + +## Primjer + +Kreirajte `.opencode/skills/git-release/SKILL.md` ovako: + +```markdown +--- +name: git-release +description: Create consistent releases and changelogs +license: MIT +compatibility: opencode +metadata: + audience: maintainers + workflow: github +--- + +## What I do + +- Draft release notes from merged PRs +- Propose a version bump +- Provide a copy-pasteable `gh release create` command + +## When to use me + +Use this when you are preparing a tagged release. +Ask clarifying questions if the target versioning scheme is unclear. +``` + +--- + +## Opis alata + +OpenCode navodi dostupne skills u opisu `skill` alata. +Svaki unos sadrzi naziv i opis skill-a: + +```xml + + + git-release + Create consistent releases and changelogs + + +``` + +Agent ucitava skill pozivom alata: + +``` +skill({ name: "git-release" }) +``` + +--- + +## Konfiguracija dozvola + +Kontrolisite kojim skills agenti mogu pristupiti pomocu dozvola baziranih na obrascima u `opencode.json`: + +```json +{ + "permission": { + "skill": { + "*": "allow", + "pr-review": "allow", + "internal-*": "deny", + "experimental-*": "ask" + } + } +} +``` + +| Dozvola | Ponasanje | +| ------- | ------------------------------------------- | +| `allow` | Skill se ucitava odmah | +| `deny` | Skill je skriven od agenta, pristup odbijen | +| `ask` | Korisnik mora odobriti prije ucitavanja | + +Obrasci podrzavaju wildcard znakove: `internal-*` poklapa `internal-docs`, `internal-tools` itd. + +--- + +## Nadjačavanje po agentu + +Dajte odredenim agentima drugacije dozvole od globalnih defaulta. + +**Za prilagodene agente** (u frontmatter-u agenta): + +```yaml +--- +permission: + skill: + "documents-*": "allow" +--- +``` + +**Za ugradene agente** (u `opencode.json`): + +```json +{ + "agent": { + "plan": { + "permission": { + "skill": { + "internal-*": "allow" + } + } + } + } +} +``` + +--- + +## Isključivanje skill alata + +Potpuno iskljucite skills za agente koji ih ne bi trebali koristiti: + +**Za prilagodene agente**: + +```yaml +--- +tools: + skill: false +--- +``` + +**Za ugradene agente**: + +```json +{ + "agent": { + "plan": { + "tools": { + "skill": false + } + } + } +} +``` + +Kada je iskljuceno, sekcija `` se potpuno izostavlja. + +--- + +## Rješavanje problema s učitavanjem + +Ako se skill ne pojavi: + +1. Provjerite da je naziv `SKILL.md` napisan velikim slovima +2. Provjerite da frontmatter sadrzi `name` i `description` +3. Potvrdite da su nazivi skill-ova jedinstveni na svim lokacijama +4. Provjerite dozvole - skills sa `deny` su skriveni od agenata diff --git a/packages/web/src/content/docs/bs/themes.mdx b/packages/web/src/content/docs/bs/themes.mdx new file mode 100644 index 0000000000000000000000000000000000000000..edb17e6fa4a875220d245e8e6d988fb12f381570 --- /dev/null +++ b/packages/web/src/content/docs/bs/themes.mdx @@ -0,0 +1,369 @@ +--- +title: Teme +description: Izaberite ugradenu temu ili napravite svoju. +--- + +U OpenCode mozete birati izmedu vise ugradenih tema, koristiti temu koja se prilagodava terminalu ili definisati vlastitu temu. + +Po defaultu, OpenCode koristi nasu `opencode` temu. + +--- + +## Zahtjevi terminala + +Da bi teme bile prikazane ispravno sa punom paletom boja, terminal mora podrzavati **truecolor** (24-bitne boje). Vecina modernih terminala to podrzava, ali nekad ga treba ukljuciti: + +- **Provjerite podrsku**: Pokrenite `echo $COLORTERM` - trebalo bi vratiti `truecolor` ili `24bit` +- **Ukljucite truecolor**: Postavite varijablu okruzenja `COLORTERM=truecolor` u shell profilu +- **Kompatibilnost terminala**: Potvrdite da emulator terminala podrzava 24-bitne boje (vecina modernih terminala kao iTerm2, Alacritty, Kitty, Windows Terminal i novije verzije GNOME Terminala) + +Bez truecolor podrske, teme mogu imati slabiju preciznost boja ili pasti na najblizu 256-color aproksimaciju. + +--- + +## Ugrađene teme + +OpenCode dolazi sa vise ugradenih tema. + +| Naziv | Opis | +| ---------------------- | -------------------------------------------------------------------------- | +| `system` | Prilagodava se boji pozadine vaseg terminala | +| `tokyonight` | Bazirana na [Tokyonight](https://github.com/folke/tokyonight.nvim) temi | +| `everforest` | Bazirana na [Everforest](https://github.com/sainnhe/everforest) temi | +| `ayu` | Bazirana na [Ayu](https://github.com/ayu-theme) dark temi | +| `catppuccin` | Bazirana na [Catppuccin](https://github.com/catppuccin) temi | +| `catppuccin-macchiato` | Bazirana na [Catppuccin](https://github.com/catppuccin) temi | +| `gruvbox` | Bazirana na [Gruvbox](https://github.com/morhetz/gruvbox) temi | +| `kanagawa` | Bazirana na [Kanagawa](https://github.com/rebelot/kanagawa.nvim) temi | +| `nord` | Bazirana na [Nord](https://github.com/nordtheme/nord) temi | +| `matrix` | Hacker stil zelena-na-crnom tema | +| `one-dark` | Bazirana na [Atom One](https://github.com/Th3Whit3Wolf/one-nvim) Dark temi | + +I jos mnogo njih, stalno dodajemo nove teme. + +--- + +## System tema + +`system` tema je napravljena da se automatski prilagodi sem i boja vaseg terminala. Za razliku od tradicionalnih tema sa fiksnim bojama, _system_ tema: + +- **Generise sivu skalu**: Pravi prilagodenu sivu skalu na osnovu boje pozadine terminala za optimalan kontrast. +- **Koristi ANSI boje**: Koristi standardne ANSI boje (0-15) za sintaksno isticanje i UI elemente, uz postovanje palete terminala. +- **Cuva terminalske defaulte**: Koristi `none` za boju teksta i pozadine da zadrzi izvorni izgled terminala. + +System tema je za korisnike koji: + +- Zele da OpenCode odgovara izgledu njihovog terminala +- Koriste prilagodene seme boja terminala +- Preferiraju konzistentan izgled kroz sve terminalske aplikacije + +--- + +## Korištenje teme + +Temu mozete izabrati preko selektora tema komandom `/theme`. Ili je možete navesti u `tui.json`. + +```json title="tui.json" {3} +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "tokyonight" +} +``` + +--- + +## Prilagođene teme + +OpenCode podrzava fleksibilan sistem tema baziran na JSON-u koji olaksava kreiranje i prilagodavanje tema. + +--- + +### Hijerarhija + +Teme se ucitavaju iz vise direktorija ovim redoslijedom, gdje kasniji direktoriji prepisuju ranije: + +1. **Ugradene teme** - Ugradene su u binarni fajl +2. **Korisnicki config direktorij** - `~/.config/opencode/themes/*.json` ili `$XDG_CONFIG_HOME/opencode/themes/*.json` +3. **Korijenski direktorij projekta** - `/.opencode/themes/*.json` +4. **Trenutni radni direktorij** - `./.opencode/themes/*.json` + +Ako vise direktorija sadrzi temu istog naziva, koristit ce se tema iz direktorija s vecim prioritetom. + +--- + +### Kreiranje teme + +Da kreirate prilagodenu temu, napravite JSON datoteku u jednom od direktorija za teme. + +Za korisnicke teme na nivou sistema: + +```bash no-frame +mkdir -p ~/.config/opencode/themes +vim ~/.config/opencode/themes/my-theme.json +``` + +I za teme specificne za projekat. + +```bash no-frame +mkdir -p .opencode/themes +vim .opencode/themes/my-theme.json +``` + +--- + +### JSON struktura + +Teme koriste fleksibilan JSON format koji podrzava: + +- **Hex boje**: `"#ffffff"` +- **ANSI boje**: `3` (0-255) +- **Reference boja**: `"primary"` ili prilagodene definicije +- **Dark/light varijante**: `{"dark": "#000", "light": "#fff"}` +- **Bez boje**: `"none"` - koristi defaultnu boju terminala ili transparentno + +--- + +### Definicije boja + +Sekcija `defs` je opcionalna i omogucava da definisete boje koje se mogu ponovo koristiti kroz temu. + +--- + +### Terminalske zadane vrijednosti + +Specijalna vrijednost `"none"` moze se koristiti za bilo koju boju da naslijedi defaultnu boju terminala. Ovo je korisno za teme koje se prirodno uklapaju u semu boja terminala: + +- `"text": "none"` - koristi defaultnu boju teksta terminala +- `"background": "none"` - koristi defaultnu boju pozadine terminala + +--- + +### Primjer + +Evo primjera prilagodene teme: + +```json title="my-theme.json" +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD" + }, + "theme": { + "primary": { + "dark": "nord8", + "light": "nord10" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord4", + "light": "nord0" + }, + "textMuted": { + "dark": "nord3", + "light": "nord1" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord1", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "nord3", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "nord3", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "nord2", + "light": "nord4" + }, + "diffAddedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "nord3", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "nord3", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "nord3", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" + } + } +} +``` diff --git a/packages/web/src/content/docs/bs/tools.mdx b/packages/web/src/content/docs/bs/tools.mdx new file mode 100644 index 0000000000000000000000000000000000000000..c2d5aa2dd2df2871cddf51c7ccf23e321f59f914 --- /dev/null +++ b/packages/web/src/content/docs/bs/tools.mdx @@ -0,0 +1,341 @@ +--- +title: Alati +description: Upravljajte alatima koje LLM moze koristiti. +--- + +Alati omogucavaju LLM-u da izvrsava akcije u vasem kodu. OpenCode dolazi sa skupom ugradenih alata, a mozete ga prosiriti kroz [custom tools](/docs/custom-tools) ili [MCP servers](/docs/mcp-servers). + +Po defaultu su svi alati **ukljuceni** i ne traze dozvolu za pokretanje. Ponasanje alata kontrolisete kroz [permissions](/docs/permissions). + +--- + +## Konfiguracija + +Koristite polje `permission` za kontrolu ponasanja alata. Za svaki alat mozete postaviti allow, deny ili ask. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny", + "bash": "ask", + "webfetch": "allow" + } +} +``` + +Mozete koristiti i wildcard obrasce da kontrolisete vise alata odjednom. Na primjer, da trazite odobrenje za sve alate jednog MCP servera: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "mymcp_*": "ask" + } +} +``` + +[Saznajte vise](/docs/permissions) o konfigurisanju dozvola. + +--- + +## Ugrađeni + +Ovo su svi ugradeni alati dostupni u OpenCode. + +--- + +### bash + +Izvrsava shell komande u okruzenju projekta. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": "allow" + } +} +``` + +Ovaj alat omogucava LLM-u da pokrece terminalske komande kao `npm install`, `git status` i druge shell komande. + +--- + +### edit + +Mijenja postojece datoteke tacnim zamjenama stringova. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +Ovaj alat radi precizne izmjene datoteka zamjenom tacnih poklapanja teksta. To je glavni nacin na koji LLM mijenja kod. + +--- + +### write + +Kreira nove datoteke ili prepisuje postojece. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +Koristite ovo da dozvolite LLM-u kreiranje novih datoteka. Ako datoteka vec postoji, bit ce prepisana. + +:::note +`write` alat kontrolise `edit` dozvola, koja pokriva sve izmjene datoteka (`edit`, `write`, `patch`). +::: + +--- + +### read + +Cita sadrzaj datoteka iz vaseg koda. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "read": "allow" + } +} +``` + +Ovaj alat cita datoteke i vraca njihov sadrzaj. Podrzava citanje odredenih raspona linija kod velikih fajlova. + +--- + +### grep + +Pretrazuje sadrzaj datoteka pomocu regularnih izraza. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "grep": "allow" + } +} +``` + +Brza pretraga sadrzaja kroz cijeli kod. Podrzava puni regex i filtriranje po obrascima datoteka. + +--- + +### glob + +Pronalazi datoteke po obrascima. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "glob": "allow" + } +} +``` + +Trazi datoteke koristeci glob obrasce kao `**/*.js` ili `src/**/*.ts`. Vraca putanje sortirane po vremenu izmjene. + +--- + +### lsp (eksperimentalno) + +Komunicira sa konfigurisanim LSP serverima za funkcije inteligencije koda kao definicije, reference, hover info i hijerarhija poziva. + +:::note +Ovaj alat je dostupan samo kada je `OPENCODE_EXPERIMENTAL_LSP_TOOL=true` (ili `OPENCODE_EXPERIMENTAL=true`). +::: + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "lsp": "allow" + } +} +``` + +Podrzane operacije ukljucuju `goToDefinition`, `findReferences`, `hover`, `documentSymbol`, `workspaceSymbol`, `goToImplementation`, `prepareCallHierarchy`, `incomingCalls` i `outgoingCalls`. + +Za konfiguraciju dostupnih LSP servera u projektu, pogledajte [LSP Servers](/docs/lsp). + +--- + +### patch + +Primjenjuje zakrpe na datoteke. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +Ovaj alat primjenjuje patch datoteke na kod. Koristan je za diffs i patch-eve iz razlicitih izvora. + +:::note +`patch` alat kontrolise `edit` dozvola, koja pokriva sve izmjene datoteka (`edit`, `write`, `patch`). +::: + +--- + +### skill + +Ucitajte [skill](/docs/skills) (`SKILL.md` datoteku) i vratite njegov sadrzaj u razgovor. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "skill": "allow" + } +} +``` + +--- + +### todowrite + +Upravlja todo listama tokom coding sesija. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "todowrite": "allow" + } +} +``` + +Kreira i azurira liste zadataka za pracenje napretka tokom slozenih operacija. LLM ovo koristi za organizaciju zadataka u vise koraka. + +:::note +Ovaj alat je po defaultu iskljucen za subagente, ali ga mozete rucno ukljuciti. [Saznajte vise](/docs/agents/#permissions) +::: + +--- + +### webfetch + +Preuzima web sadrzaj. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "webfetch": "allow" + } +} +``` + +Omogucava LLM-u da preuzima i cita web stranice. Korisno za dokumentaciju i online istrazivanje. + +--- + +### websearch + +Pretrazuje web za informacije. + +:::note +Ovaj alat je dostupan samo uz OpenCode provajdera ili kada je varijabla `OPENCODE_ENABLE_EXA` postavljena na truthy vrijednost (npr. `true` ili `1`). + +Da ukljucite pri pokretanju OpenCode: + +```bash +OPENCODE_ENABLE_EXA=1 opencode +``` + +::: + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "websearch": "allow" + } +} +``` + +Vrsi web pretrage preko Exa AI da pronade relevantne informacije online. Korisno za istrazivanje tema, aktuelnosti i podataka van granice trening skupa. + +API kljuc nije potreban - alat se direktno povezuje na Exa AI hosted MCP servis bez autentifikacije. + +:::tip +Koristite `websearch` kada trebate pronaci informacije (discovery), a `webfetch` kada trebate preuzeti sadrzaj sa konkretnog URL-a (retrieval). +::: + +--- + +### question + +Postavlja korisniku pitanja tokom izvrsavanja. + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "question": "allow" + } +} +``` + +Ovaj alat omogucava LLM-u da postavlja pitanja korisniku tokom zadatka. Koristan je za: + +- Prikupljanje korisnickih preferencija i zahtjeva +- Razjasnjavanje nejasnih uputstava +- Donosenje odluka oko implementacije +- Nudjenje izbora o smjeru rada + +Svako pitanje ukljucuje naslov, tekst pitanja i listu opcija. Korisnici mogu izabrati ponudenu opciju ili upisati vlastiti odgovor. Kada ima vise pitanja, mogu se kretati izmedu njih prije slanja svih odgovora. + +--- + +## Prilagođeni alati + +Custom tools vam omogucavaju da definisete vlastite funkcije koje LLM moze pozivati. Definisu se u config datoteci i mogu izvrsavati proizvoljan kod. + +[Saznajte vise](/docs/custom-tools) o kreiranju custom tools. + +--- + +## MCP serveri + +MCP (Model Context Protocol) serveri omogucavaju integraciju eksternih alata i servisa. Ovo ukljucuje pristup bazama, API integracije i third-party servise. + +[Saznajte vise](/docs/mcp-servers) o konfigurisanju MCP servera. + +--- + +## Interno + +Interno, alati kao `grep` i `glob` koriste [ripgrep](https://github.com/BurntSushi/ripgrep). Po defaultu, ripgrep postuje `.gitignore` obrasce, pa se fajlovi i direktoriji iz `.gitignore` izostavljaju iz pretraga i listinga. + +--- + +### Obrasci ignorisanja + +Da ukljucite fajlove koji bi inace bili ignorisani, kreirajte `.ignore` datoteku u korijenu projekta. Ova datoteka moze eksplicitno dozvoliti odredene putanje. + +```text title=".ignore" +!node_modules/ +!dist/ +!build/ +``` + +Na primjer, ova `.ignore` datoteka dozvoljava ripgrep-u da pretrazuje `node_modules/`, `dist/` i `build/` direktorije i kada su navedeni u `.gitignore`. diff --git a/packages/web/src/content/docs/bs/troubleshooting.mdx b/packages/web/src/content/docs/bs/troubleshooting.mdx new file mode 100644 index 0000000000000000000000000000000000000000..7dff4b24c089ed56e3c5e3af47427a72d84a197b --- /dev/null +++ b/packages/web/src/content/docs/bs/troubleshooting.mdx @@ -0,0 +1,300 @@ +--- +title: Rješavanje problema +description: Uobičajeni problemi i kako ih riješiti. +--- + +Da biste otklonili probleme s OpenCode, počnite provjeravanjem dnevnika i lokalnih podataka koje pohranjuje na disku. + +--- + +## Dnevnici + +Log fajlovi se pišu na: + +- **macOS/Linux**: `~/.local/share/opencode/log/` +- **Windows**: Pritisnite `WIN+R` i zalijepite `%USERPROFILE%\.local\share\opencode\log` + +Datoteke evidencije se imenuju vremenskim oznakama (npr. `2025-01-09T123456.log`) i čuvaju se najnovijih 10 datoteka dnevnika. + +Možete postaviti nivo dnevnika pomoću opcije komandne linije `--log-level` da biste dobili detaljnije informacije o otklanjanju grešaka. Na primjer, `opencode --log-level DEBUG`. + +--- + +## Pohrana + +OpenCode pohranjuje podatke o sesiji i druge podatke aplikacije na disku na: + +- **macOS/Linux**: `~/.local/share/opencode/` +- **Windows**: Pritisnite `WIN+R` i zalijepite `%USERPROFILE%\.local\share\opencode` + +Ovaj direktorij sadrži: + +- `auth.json` - ​​Podaci o autentifikaciji kao što su API ključevi, OAuth tokeni +- `log/` - ​​Dnevnici aplikacije +- `project/` - ​​Podaci specifični za projekat kao što su podaci o sesiji i poruci + - Ako je projekat unutar Git repo-a, on je pohranjen u `.//storage/` + - Ako nije Git repo, pohranjuje se u `./global/storage/` + +--- + +## Desktop aplikacija + +OpenCode Desktop pokreće lokalni OpenCode server (`opencode-cli` sidecar) u pozadini. Većina problema je uzrokovana nedostatkom dodatka, oštećenom keš memorijom ili lošim postavkama servera. + +### Brze provjere + +- Potpuno zatvorite i ponovo pokrenite aplikaciju. +- Ako aplikacija prikaže ekran s greškom, kliknite na **Restart** i kopirajte detalje o grešci. +- samo za macOS: `OpenCode` meni -> **Ponovo učitaj Webview** (pomaže ako je korisnički interfejs prazan/zamrznut). + +--- + +### Onemogućavanje dodataka + +Ako se desktop aplikacija ruši pri pokretanju, visi ili se čudno ponaša, počnite s onemogućavanjem dodataka. + +#### Provjerite globalnu konfiguraciju + +Otvorite svoju globalnu konfiguracijsku datoteku i potražite ključ `plugin`. + +- **macOS/Linux**: `~/.config/opencode/opencode.jsonc` (ili `~/.config/opencode/opencode.json`) +- **macOS/Linux** (starije instalacije): `~/.local/share/opencode/opencode.jsonc` +- **Windows**: Pritisnite `WIN+R` i zalijepite `%USERPROFILE%\.config\opencode\opencode.jsonc` + +Ako imate konfigurirane dodatke, privremeno ih onemogućite uklanjanjem ključa ili postavljanjem na prazan niz: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [], +} +``` + +#### Provjera direktorija dodataka + +OpenCode također može učitati lokalne dodatke s diska. Privremeno ih maknite s puta (ili preimenujte folder) i ponovo pokrenite desktop aplikaciju: + +- **Globalni dodaci** + - **macOS/Linux**: `~/.config/opencode/plugins/` + - **Windows**: Pritisnite `WIN+R` i zalijepite `%USERPROFILE%\.config\opencode\plugins` +- **Projektni dodaci** (samo ako koristite konfiguraciju po projektu) + - `/.opencode/plugins/` + +Ako aplikacija ponovo počne raditi, ponovo omogućite dodatke jedan po jedan kako biste otkrili koji od njih uzrokuje problem. + +--- + +### Brisanje keš memorije + +Ako onemogućavanje dodataka ne pomogne (ili se instalacija dodatka zaglavila), obrišite keš memoriju kako bi ga OpenCode mogao ponovo izgraditi. + +1. Potpuno zatvorite OpenCode Desktop. +2. Izbrišite keš direktorij: + +- **macOS**: Finder -> `Cmd+Shift+G` -> zalijepi `~/.cache/opencode` +- **Linux**: obrišite `~/.cache/opencode` (ili pokrenite `rm -rf ~/.cache/opencode`) +- **Windows**: Pritisnite `WIN+R` i zalijepite `%USERPROFILE%\.cache\opencode` + +3. Ponovo pokrenite OpenCode Desktop. + +--- + +### Rješavanje problema sa vezom na serveru + +OpenCode Desktop može ili pokrenuti svoj lokalni server (podrazumevano) ili se povezati na URL servera koji ste konfigurisali. + +Ako vidite dijaloški okvir **"Povezivanje nije uspjelo"** (ili aplikacija nikada ne prođe kroz početni ekran), provjerite da li postoji prilagođeni URL servera. + +#### Obrišite zadani URL servera radne površine + +Na početnom ekranu kliknite na ime servera (sa tačkom statusa) da otvorite birač servera. U odjeljku **Podrazumevani server** kliknite na **Obriši**. + +#### Uklonite `server.port` / `server.hostname` iz vaše konfiguracije + +Ako vaš `opencode.json(c)` sadrži odjeljak `server`, privremeno ga uklonite i ponovo pokrenite desktop aplikaciju. + +#### Provjerite varijable okruženja + +Ako ste postavili `OPENCODE_PORT` u svom okruženju, desktop aplikacija će pokušati da koristi taj port za lokalni server. + +- Poništite `OPENCODE_PORT` (ili odaberite slobodan port) i ponovo pokrenite. + +--- + +### Linux: Wayland / X11 problemi + +Na Linuxu, neka podešavanja Waylanda mogu uzrokovati prazne prozore ili greške sastavljača. + +- Ako ste na Waylandu, a aplikacija je prazna/ispada, pokušajte pokrenuti sa `OC_ALLOW_WAYLAND=1`. +- Ako to pogorša stvari, uklonite ga i pokušajte pokrenuti pod X11 sesijom umjesto toga. + +--- + +### Windows: WebView2 izvršno okruženje + +Na Windows-u, OpenCode Desktop zahtijeva Microsoft Edge **WebView2 Runtime**. Ako se aplikacija otvori u praznom prozoru ili se ne pokrene, instalirajte/ažurirajte WebView2 i pokušajte ponovo. + +--- + +### Windows: Opšti problemi sa performansama + +Ako imate spore performanse, probleme s pristupom datotekama ili probleme s terminalom na Windows-u, pokušajte koristiti [WSL (Windows podsistem za Linux)](/docs/windows-wsl). WSL pruža Linux okruženje koje radi neprimetnije sa OpenCode karakteristikama. + +--- + +### Obavještenja se ne prikazuju + +OpenCode Desktop prikazuje sistemska obavještenja samo kada: + +- obavještenja su omogućena za OpenCode u postavkama vašeg OS-a, i +- prozor aplikacije nije fokusiran. + +--- + +### Resetovanje pohrane desktop aplikacije + +Ako se aplikacija ne pokrene i ne možete izbrisati postavke unutar korisničkog sučelja, resetirajte spremljeno stanje desktop aplikacije. + +1. Zatvorite OpenCode Desktop. +2. Pronađite i izbrišite ove datoteke (oni žive u direktoriju podataka OpenCode Desktop aplikacije): + +- `opencode.settings.dat` (URL zadanog servera za desktop) +- `opencode.global.dat` i `opencode.workspace.*.dat` (stanje korisničkog interfejsa poput nedavnih servera/projekata) + +Da brzo pronađete direktorij: + +- **macOS**: Finder -> `Cmd+Shift+G` -> `~/Library/Application Support` (onda potražite nazive fajlova iznad) +- **Linux**: potražite nazive fajlova iznad pod `~/.local/share` +- **Windows**: Pritisnite `WIN+R` -> `%APPDATA%` (zatim potražite nazive fajlova iznad) + +--- + +## Traženje pomoći + +Ako imate problema s OpenCode: + +1. **Prijavite probleme na GitHub** + + Najbolji način da prijavite greške ili zatražite funkcije je putem našeg GitHub spremišta: + + [**github.com/anomalyco/opencode/issues**](https://github.com/anomalyco/opencode/issues) + + Prije kreiranja novog problema, pretražite postojeće probleme da vidite je li vaš problem već prijavljen. + +2. **Pridružite se našem Discordu** + + Za pomoć u stvarnom vremenu i diskusiju u zajednici, pridružite se našem Discord serveru: + + [**opencode.ai/discord**](https://opencode.ai/discord) + +--- + +## Uobičajeni problemi + +Evo nekih uobičajenih problema i kako ih riješiti. + +--- + +### OpenCode se ne pokreće + +1. Provjerite dnevnike za poruke o greškama +2. Pokušajte pokrenuti sa `--print-logs` da vidite izlaz u terminalu +3. Uvjerite se da imate najnoviju verziju sa `opencode upgrade` + +--- + +### Problemi s autentifikacijom + +1. Pokušajte ponovo autentifikovati sa naredbom `/connect` u TUI +2. Provjerite da li su vaši API ključevi važeći +3. Uvjerite se da vaša mreža dozvoljava veze s API-jem provajdera + +--- + +### Model nije dostupan + +1. Provjerite jeste li se autentifikovali kod provajdera +2. Provjerite je li naziv modela u vašoj konfiguraciji tačan +3. Neki modeli mogu zahtijevati poseban pristup ili pretplate + +Ako naiđete na `ProviderModelNotFoundError` najvjerovatnije niste u pravu +referenciranje modela negdje. +Modele treba referencirati ovako: `/` + +primjeri: + +- `openai/gpt-4.1` +- `openrouter/google/gemini-2.5-flash` +- `opencode/kimi-k2` + +Da saznate kojim modelima imate pristup, pokrenite `opencode models` + +--- + +### ProviderInitError + +Ako naiđete na grešku ProviderInitError, vjerovatno imate nevažeću ili oštećenu konfiguraciju. + +Da biste ovo riješili: + +1. Prvo provjerite da li je vaš provajder ispravno postavljen slijedeći [vodič za pružatelje](/docs/providers) +2. Ako se problem nastavi, pokušajte obrisati pohranjenu konfiguraciju: + +```bash + rm -rf ~/.local/share/opencode +``` + +Na Windows-u pritisnite `WIN+R` i izbrišite: `%USERPROFILE%\.local\share\opencode` + +3. Ponovo izvršite autentifikaciju kod svog provajdera koristeći naredbu `/connect` u TUI. + +--- + +### AI_APICallError i problemi sa paketom dobavljača + +Ako naiđete na greške API poziva, to može biti zbog zastarjelih paketa dobavljača. OpenCode dinamički instalira pakete dobavljača (OpenAI, Anthropic, Google, itd.) po potrebi i kešira ih lokalno. + +Da biste riješili probleme s paketom dobavljača: + +1. Obrišite keš paketa provajdera: + +```bash + rm -rf ~/.cache/opencode +``` + +Na Windows-u pritisnite `WIN+R` i izbrišite: `%USERPROFILE%\.cache\opencode` + +2. Ponovo pokrenite OpenCode da ponovo instalirate najnovije pakete dobavljača + +Ovo će prisiliti OpenCode da preuzme najnovije verzije paketa dobavljača, što često rješava probleme kompatibilnosti s parametrima modela i promjenama API-ja. + +--- + +### Copy/paste ne radi na Linuxu + +Korisnici Linuxa moraju imati instaliran jedan od sljedećih uslužnih programa međuspremnika da bi funkcionirala funkcionalnost kopiranja/lijepljenja: + +**Za X11 sisteme:** + +```bash +apt install -y xclip +# or +apt install -y xsel +``` + +**Za Wayland sisteme:** + +```bash +apt install -y wl-clipboard +``` + +**Za okruženja bez glave:** + +```bash +apt install -y xvfb +# and run: +Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & +export DISPLAY=:99.0 +``` + +OpenCode će otkriti da li koristite Wayland i preferirate `wl-clipboard`, u suprotnom će pokušati pronaći alate međuspremnika po redoslijedu: `xclip` i `xsel`. diff --git a/packages/web/src/content/docs/bs/tui.mdx b/packages/web/src/content/docs/bs/tui.mdx new file mode 100644 index 0000000000000000000000000000000000000000..98c7c4d72826651283f6070978e9c23734c808d3 --- /dev/null +++ b/packages/web/src/content/docs/bs/tui.mdx @@ -0,0 +1,432 @@ +--- +title: TUI +description: Korištenje korisničkog interfejsa OpenCode terminala. +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" + +OpenCode pruža interaktivni terminalski interfejs ili TUI za rad na vašim projektima sa LLM. + +Pokretanje OpenCode pokreće TUI za trenutni direktorij. + +```bash +opencode +``` + +Ili ga možete pokrenuti za određeni radni direktorij. + +```bash +opencode /path/to/project +``` + +Kada uđete u TUI, možete to zatražiti porukom. + +```text +Give me a quick summary of the codebase. +``` + +--- + +## Reference datoteka + +Možete referencirati datoteke u svojim porukama koristeći `@`. Ovo vrši nejasnu pretragu datoteka u trenutnom radnom direktoriju. + +:::tip +Također možete koristiti `@` da referencirate datoteke u svojim porukama. +::: + +```text "@packages/functions/src/api/index.ts" +How is auth handled in @packages/functions/src/api/index.ts? +``` + +Sadržaj datoteke se automatski dodaje u razgovor. + +--- + +## Bash naredbe + +Započnite poruku sa `!` da pokrenete komandu ljuske. + +```bash frame="none" +!ls -la +``` + +Izlaz naredbe se dodaje u razgovor kao rezultat alata. + +--- + +## Naredbe + +Kada koristite OpenCode TUI, možete upisati `/` nakon čega slijedi ime komande da biste brzo izvršili radnje. na primjer: + +```bash frame="none" +/help +``` + +Većina naredbi također ima vezu pomoću `ctrl+x` kao vodeće tipke, gdje je `ctrl+x` zadani vodeći ključ. [Saznajte više](/docs/keybinds). + +Ovdje su sve dostupne komande kose crte: + +--- + +### connect + +Dodajte provajdera u OpenCode. Omogućava vam da odaberete između dostupnih provajdera i dodate njihove API ključeve. + +```bash frame="none" +/connect +``` + +--- + +### compact + +Sažimanje trenutne sesije. _Alias_: `/summarize` + +```bash frame="none" +/compact +``` + +**Tastatura:** `ctrl+x c` + +--- + +### details + +Prebacite detalje o izvršavanju alata. + +```bash frame="none" +/details +``` + +**Tastatura:** `ctrl+x d` + +--- + +### editor + +Otvorite vanjski uređivač za sastavljanje poruka. Koristi editor postavljen u vašoj varijabli okruženja `EDITOR`. [Saznajte više](#editor-setup). + +```bash frame="none" +/editor +``` + +**Tastatura:** `ctrl+x e` + +--- + +### exit + +Izađite iz OpenCode. _Aliases_: `/quit`, `/q` + +```bash frame="none" +/exit +``` + +**Tastatura:** `ctrl+x q` + +--- + +### export + +Izvezite trenutni razgovor u Markdown i otvorite ga u zadanom uređivaču. Koristi editor postavljen u vašoj varijabli okruženja `EDITOR`. [Saznajte više](#editor-setup). + +```bash frame="none" +/export +``` + +**Tastatura:** `ctrl+x x` + +--- + +### help + +Prikaži dijalog pomoći. + +```bash frame="none" +/help +``` + +**Tastatura:** `ctrl+x h` + +--- + +### init + +Kreirajte ili ažurirajte datoteku `AGENTS.md`. [Saznajte više](/docs/rules). + +```bash frame="none" +/init +``` + +**Tastatura:** `ctrl+x i` + +--- + +### models + +Navedite dostupne modele. + +```bash frame="none" +/models +``` + +**Tastatura:** `ctrl+x m` + +--- + +### new + +Započnite novu sesiju. _Alias_: `/clear` + +```bash frame="none" +/new +``` + +**Tastatura:** `ctrl+x n` + +--- + +### redo + +Ponovite prethodno poništenu poruku. Dostupno samo nakon korištenja `/undo`. + +:::tip +Sve promjene fajla će također biti vraćene. +::: + +Interno, ovo koristi Git za upravljanje promjenama datoteke. Dakle, vaš projekat **treba +biti Git spremište**. + +```bash frame="none" +/redo +``` + +**Tastatura:** `ctrl+x r` + +--- + +### sessions + +Listanje i prebacivanje između sesija. _Aliases_: `/resume`, `/continue` + +```bash frame="none" +/sessions +``` + +**Tastatura:** `ctrl+x l` + +--- + +### share + +Podijelite trenutnu sesiju. [Saznajte više](/docs/share). + +```bash frame="none" +/share +``` + +**Tastatura:** `ctrl+x s` + +--- + +### themes + +Navedite dostupne teme. + +```bash frame="none" +/themes +``` + +**Tastatura:** `ctrl+x t` + +--- + +### thinking + +Uključite/isključite vidljivost blokova razmišljanja/rezoniranja u razgovoru. Kada je omogućeno, možete vidjeti proces rezonovanja modela za modele koji podržavaju prošireno razmišljanje. + +:::note +Ova naredba samo kontrolira da li se blokovi razmišljanja **prikažu** - ne omogućava niti onemogućuje mogućnosti razmišljanja modela. Da biste uključili stvarne mogućnosti zaključivanja, koristite `ctrl+t` za kretanje kroz varijante modela. +::: + +```bash frame="none" +/thinking +``` + +--- + +### undo + +Poništi posljednju poruku u razgovoru. Uklanja najnoviju korisničku poruku, sve naknadne odgovore i sve promjene datoteke. + +:::tip +Sve promjene u fajlu će također biti poništene. +::: + +Interno, ovo koristi Git za upravljanje promjenama datoteke. Dakle, vaš projekat **treba +biti Git spremište**. + +```bash frame="none" +/undo +``` + +**Tastatura:** `ctrl+x u` + +--- + +### unshare + +Poništi dijeljenje trenutne sesije. [Saznajte više](/docs/share#un-sharing). + +```bash frame="none" +/unshare +``` + +--- + +## Podešavanje uređivača + +Obje naredbe `/editor` i `/export` koriste editor specificiran u vašoj varijabli okruženja `EDITOR`. + + + + + ```bash + # Example for nano or vim + export EDITOR=nano + export EDITOR=vim + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + export EDITOR="code --wait" + ``` + + Da biste ga učinili trajnim, dodajte ovo u svoj shell profil; + `~/.bashrc`, `~/.zshrc`, itd. + + + + + + ```bash + set EDITOR=notepad + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + set EDITOR=code --wait + ``` + + Da biste ga učinili trajnim, koristite **Svojstva sistema** > **Okruženje + Varijable**. + + + + + + ```powershell + $env:EDITOR = "notepad" + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + $env:EDITOR = "code --wait" + ``` + + Da biste ga učinili trajnim, dodajte ovo u svoj PowerShell profil. + + + + +Popularne opcije uređivača uključuju: + +- `code` - ​​Visual Studio Code +- `cursor` - ​​Cursor +- `windsurf` - ​​Windsurf +- `nvim` - ​​Neovim editorom +- `vim` - ​​Vim editor +- `nano` - ​​Nano editor +- `notepad` - ​​Windows Notepad +- `subl` - ​​Sublime Text + +:::note +Neki uređivači kao što je VS Code moraju biti pokrenuti sa `--wait` zastavicom. +::: + +Nekim uređivačima su potrebni argumenti komandne linije da bi se pokrenuli u načinu blokiranja. Oznaka `--wait` blokira proces uređivača dok se ne zatvori. + +--- + +## Konfiguracija + +Možete prilagoditi TUI ponašanje putem `tui.json` (ili `tui.jsonc`). + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "opencode", + "leader_timeout": 2000, + "keybinds": { + "leader": "ctrl+x", + "command_list": "ctrl+p" + }, + "scroll_speed": 3, + "scroll_acceleration": { + "enabled": false + }, + "diff_style": "auto", + "mouse": true, + "attention": { + "enabled": true, + "notifications": true, + "sound": true, + "volume": 0.4, + "sound_pack": "opencode.default", + "sounds": { + "error": "./sounds/error.mp3" + } + } +} +``` + +Ovo je odvojeno od `opencode.json`, koji konfiguriše ponašanje servera/izvršavanja. + +`keybinds` se spaja s ugrađenim zadanim vrijednostima, tako da trebate konfigurisati samo prečice koje želite promijeniti. + +### Opcije + +- `theme` - Postavlja vašu UI temu. [Saznajte više](/docs/themes). +- `keybinds` - Prilagođava prečice na tastaturi. [Saznajte više](/docs/keybinds). +- `leader_timeout` - Kontroliše koliko dugo OpenCode čeka nakon leader key. Podrazumijevano je `2000`. +- `scroll_acceleration.enabled` - ​​Omogućite ubrzanje pomicanja u macOS stilu za glatko, prirodno pomicanje. Kada je omogućeno, brzina pomicanja se povećava brzim pokretima pomicanja i ostaje precizna za sporije pokrete. **Ova postavka ima prednost nad `scroll_speed` i nadjačava je kada je omogućena.** +- `scroll_speed` - ​​Kontrolira koliko brzo TUI skroluje kada se koriste komande za pomeranje (minimum: `0.001`, podržava decimalne vrijednosti). Podrazumevano je `3`. **Napomena: Ovo se zanemaruje ako je `scroll_acceleration.enabled` postavljeno na `true`.** +- `diff_style` - Kontrolira prikazivanje razlike. `"auto"` se prilagođava širini terminala, `"stacked"` uvijek prikazuje raspored u jednoj koloni. +- `mouse` - Omogućava ili onemogućava hvatanje miša u TUI (podrazumijevano: `true`). Kada je onemogućeno, zadržava se izvorno ponašanje terminala za označavanje i pomicanje mišem. +- `attention` - Konfiguriše desktop obavještenja i zvukove za TUI. Podrazumijevano je onemogućeno. + +Koristite `OPENCODE_TUI_CONFIG` da učitate prilagođenu putanju TUI konfiguracije. + +### Attention + +Attention omogućava da vas TUI obavijesti kada OpenCode čeka odgovor, treba odobrenje dozvole, prijavi grešku sesije ili završi sesiju. Omogućite ga pomoću `attention.enabled`; ugrađeni događaji reproduciraju zvuk kada se dogode. Desktop obavještenja se šalju samo kada prozor terminala nije u fokusu i ne koriste se za subagent događaje. + +- `enabled` - Omogućava sva obavještenja i zvukove za Attention. Podrazumijevano je `false`. +- `notifications` - Kada je Attention omogućen, dozvoljava TUI-ju da šalje desktop obavještenja putem terminala. Podrazumijevano je `true`. +- `sound` - Kada je Attention omogućen, dozvoljava zvukove upozorenja. Podrazumijevano je `true`. +- `volume` - Podrazumijevana jačina zvukova upozorenja od `0` do `1`. Podrazumijevano je `0.4`. +- `sound_pack` - Sound pack ID koji se koristi. Podrazumijevano je `opencode.default`. +- `sounds` - Postavlja prilagođene zvučne datoteke za `default`, `question`, `permission`, `error`, `done` ili `subagent_done`. Putanje mogu biti apsolutne, `file://` URL-ovi ili relativne u odnosu na `tui.json`. + +--- + +## Prilagođavanje + +Možete prilagoditi različite aspekte TUI prikaza koristeći paletu komandi (`ctrl+x h` ili `/help`). Ove postavke traju i nakon ponovnog pokretanja. + +--- + +#### Prikaz korisničkog imena + +Uključite da li se vaše korisničko ime pojavljuje u porukama za ćaskanje. Pristupite ovome putem: + +- Paleta naredbi: Potražite "korisničko ime" ili "sakrij korisničko ime" +- Postavka se automatski nastavlja i pamtit će se tijekom TUI sesija diff --git a/packages/web/src/content/docs/bs/web.mdx b/packages/web/src/content/docs/bs/web.mdx new file mode 100644 index 0000000000000000000000000000000000000000..6110162a97439541b991b2cbb09b0b9052741995 --- /dev/null +++ b/packages/web/src/content/docs/bs/web.mdx @@ -0,0 +1,142 @@ +--- +title: Web +description: Korišćenje OpenCode u vašem pretraživaču. +--- + +OpenCode može raditi kao web aplikacija u vašem pretraživaču, pružajući isto moćno iskustvo AI kodiranja bez potrebe za terminalom. + +![OpenCode Web - Nova sesija](../../../assets/web/web-homepage-new-session.png) + +## Početak rada + +Pokrenite web interfejs tako što ćete pokrenuti: + +```bash +opencode web +``` + +Ovo pokreće lokalni server na `127.0.0.1` sa nasumičnim dostupnim portom i automatski otvara OpenCode u vašem podrazumevanom pretraživaču. + +:::caution +Ako `OPENCODE_SERVER_PASSWORD` nije postavljen, server će biti nezaštićen. Ovo je u redu za lokalnu upotrebu, ali bi trebalo biti postavljeno za pristup mreži. +::: + +:::tip[Windows korisnici] +Za najbolje iskustvo, pokrenite `opencode web` iz [WSL](/docs/windows-wsl) umjesto PowerShell-a. Ovo osigurava pravilan pristup sistemu datoteka i integraciju terminala. +::: + +--- + +## Konfiguracija + +Možete konfigurirati web server koristeći oznake komandne linije ili u vašoj [config file](/docs/config). + +### Port + +OpenCode podrazumevano bira dostupni port. Možete odrediti port: + +```bash +opencode web --port 4096 +``` + +### Ime hosta + +Podrazumevano, server se vezuje za `127.0.0.1` (samo lokalni host). Da biste OpenCode učinili dostupnim na vašoj mreži: + +```bash +opencode web --hostname 0.0.0.0 +``` + +Kada koristite `0.0.0.0`, OpenCode će prikazati i lokalne i mrežne adrese: + +``` + Local access: http://localhost:4096 + Network access: http://192.168.1.100:4096 +``` + +### mDNS Otkrivanje + +Omogućite mDNS kako bi vaš server bio vidljiv na lokalnoj mreži: + +```bash +opencode web --mdns +``` + +Ovo automatski postavlja ime hosta na `0.0.0.0` i oglašava server kao `opencode.local`. + +Možete prilagoditi ime mDNS domene za pokretanje više instanci na istoj mreži: + +```bash +opencode web --mdns --mdns-domain myproject.local +``` + +### CORS + +Da biste omogućili dodatne domene za CORS (korisno za prilagođene frontendove): + +```bash +opencode web --cors https://example.com +``` + +### Autentifikacija + +Da biste zaštitili pristup, postavite lozinku koristeći varijablu okruženja `OPENCODE_SERVER_PASSWORD`: + +```bash +OPENCODE_SERVER_PASSWORD=secret opencode web +``` + +Korisničko ime podrazumevano je `opencode`, ali se može promeniti sa `OPENCODE_SERVER_USERNAME`. + +--- + +## Korištenje web sučelja + +Jednom pokrenut, web sučelje pruža pristup vašim OpenCode sesijama. + +### Sesije + +Pregledajte i upravljajte svojim sesijama sa početne stranice. Možete vidjeti aktivne sesije i započeti nove. + +![OpenCode Web - aktivna sesija](../../../assets/web/web-homepage-active-session.png) + +### Status servera + +Kliknite "Pogledajte servere" da vidite povezane servere i njihov status. + +![OpenCode Web - Vidi servere](../../../assets/web/web-homepage-see-servers.png) + +--- + +## Povezivanje terminala + +Možete priključiti TUI terminala na aktivni web server: + +```bash +# Start the web server +opencode web --port 4096 + +# In another terminal, attach the TUI +opencode attach http://localhost:4096 +``` + +Ovo vam omogućava da istovremeno koristite i web sučelje i terminal, dijeleći iste sesije i stanje. + +--- + +## Konfiguracioni fajl + +Također možete konfigurirati postavke servera u svom `opencode.json` konfiguracijskom fajlu: + +```json +{ + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true, + "cors": ["https://example.com"] + } +} +``` + +Oznake komandne linije imaju prednost nad postavkama konfiguracione datoteke. diff --git a/packages/web/src/content/docs/bs/windows-wsl.mdx b/packages/web/src/content/docs/bs/windows-wsl.mdx new file mode 100644 index 0000000000000000000000000000000000000000..04d62a531453982230c25283e3593c522f205511 --- /dev/null +++ b/packages/web/src/content/docs/bs/windows-wsl.mdx @@ -0,0 +1,113 @@ +--- +title: Windows (WSL) +description: Pokrenite OpenCode na Windowsu preko WSL-a. +--- + +import { Steps } from "@astrojs/starlight/components" + +Iako OpenCode moze raditi direktno na Windowsu, preporucujemo [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) za najbolje iskustvo. WSL daje Linux okruzenje koje glatko radi sa OpenCode funkcijama. + +:::tip[Zašto WSL?] +WSL nudi bolje performanse fajl sistema, punu terminalsku podrsku i kompatibilnost s razvojnim alatima na koje se OpenCode oslanja. +::: + +--- + +## Postavljanje + + + +1. **Instalirajte WSL** + + Ako vec niste, [instalirajte WSL](https://learn.microsoft.com/en-us/windows/wsl/install) prema zvanicnom Microsoft vodicu. + +2. **Instalirajte OpenCode u WSL-u** + + Kad je WSL spreman, otvorite WSL terminal i instalirajte OpenCode jednom od [metoda instalacije](/docs/). + + ```bash + curl -fsSL https://opencode.ai/install | bash + ``` + +3. **Koristite OpenCode iz WSL-a** + + Idite u direktorij projekta (Windows fajlovima pristupate preko `/mnt/c/`, `/mnt/d/` itd.) i pokrenite OpenCode. + + ```bash + cd /mnt/c/Users/YourName/project + opencode + ``` + + + +--- + +## Desktop aplikacija + WSL Server + +Ako preferirate OpenCode Desktop aplikaciju, ali zelite da server radi u WSL-u: + +1. **Pokrenite server u WSL-u** sa `--hostname 0.0.0.0` da dozvolite vanjske konekcije: + + ```bash + opencode serve --hostname 0.0.0.0 --port 4096 + ``` + +2. **Povezite Desktop aplikaciju** na `http://localhost:4096` + +:::note +Ako `localhost` ne radi u vasem setupu, povezte se preko WSL IP adrese (u WSL-u: `hostname -I`) i koristite `http://:4096`. +::: + +:::caution +Kada koristite `--hostname 0.0.0.0`, postavite `OPENCODE_SERVER_PASSWORD` da zastitite server. + +```bash +OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 +``` + +::: + +--- + +## Web klijent + WSL + +Za najbolje web iskustvo na Windowsu: + +1. **Pokrenite `opencode web` u WSL terminalu** umjesto u PowerShell-u: + + ```bash + opencode web --hostname 0.0.0.0 + ``` + +2. **Otvorite iz Windows browsera** na `http://localhost:` (OpenCode ispisuje URL) + +Pokretanje `opencode web` iz WSL-a osigurava ispravan pristup fajl sistemu i terminalsku integraciju, a i dalje je dostupno iz Windows browsera. + +--- + +## Pristup Windows fajlovima + +WSL moze pristupiti svim Windows fajlovima kroz `/mnt/` direktorij: + +- Disk `C:` → `/mnt/c/` +- Disk `D:` → `/mnt/d/` +- I tako dalje... + +Primjer: + +```bash +cd /mnt/c/Users/YourName/Documents/project +opencode +``` + +:::tip +Za najgladje iskustvo, razmislite da klonirate/kopirate repo u WSL fajl sistem (npr. pod `~/code/`) i tu pokrenete OpenCode. +::: + +--- + +## Savjeti + +- Drzite OpenCode u WSL-u za projekte na Windows diskovima - pristup fajlovima je jednostavan +- Koristite VS Code [WSL ekstenziju](https://code.visualstudio.com/docs/remote/wsl) uz OpenCode za integrisan tok rada +- Vase OpenCode konfiguracije i sesije cuvaju se u WSL okruzenju na `~/.local/share/opencode/` diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx new file mode 100644 index 0000000000000000000000000000000000000000..405e62977725e87577b33fa828308e9a815b463d --- /dev/null +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -0,0 +1,374 @@ +--- +title: Zen +description: Kurirana lista modela koje nudi OpenCode. +--- + +import config from "../../../../config.mjs" +export const console = config.console +export const email = `mailto:${config.email}` + +OpenCode Zen je lista testiranih i provjerenih modela koje pruža OpenCode tim. + +Zen radi kao i svaki drugi provajder u OpenCode. Prijavite se na OpenCode Zen i +preuzmete svoj API ključ. To je **potpuno opcionalno** i ne morate ga koristiti +da biste koristili OpenCode. + +--- + +## Pozadina + +Postoji veliki broj modela, ali samo mali broj tih modela dobro funkcioniše kao +coding agent. Osim toga, većina provajdera je konfigurisana veoma različito, pa +zbog toga dobijate veoma različite performanse i kvalitet. + +:::tip +Testirali smo odabranu grupu modela i provajdera koji dobro rade s OpenCode. +::: + +Zato, ako model koristite preko nečega poput OpenRouter, nikada ne možete biti +sigurni da dobijate najbolju verziju modela koji želite. + +Da bismo to riješili, uradili smo nekoliko stvari: + +1. Testirali smo odabranu grupu modela i razgovarali s njihovim timovima o tome + kako ih najbolje pokretati. +2. Zatim smo radili s nekoliko provajdera kako bismo bili sigurni da se ti + modeli isporučuju ispravno. +3. Na kraju smo benchmarkirali kombinacije model/provajder i sastavili listu + koju s punim povjerenjem preporučujemo. + +OpenCode Zen je AI gateway koji vam daje pristup tim modelima. + +--- + +## Kako radi + +OpenCode Zen radi kao i svaki drugi provajder u OpenCode. + +1. Prijavite se na **OpenCode Zen**, dodajte podatke za + naplatu i kopirajte svoj API ključ. +2. Pokrenite komandu `/connect` u TUI, izaberite OpenCode Zen i zalijepite svoj API ključ. +3. Pokrenite `/models` u TUI da vidite listu modela koje preporučujemo. + +Naplata se vrši po zahtjevu i možete dodavati kredit na svoj račun. + +--- + +## Endpoints + +Našim modelima možete pristupiti i preko sljedećih API endpointa. + +| Model | Model ID | Endpoint | AI SDK Package | +| ------------------------------- | ------------------------------- | --------------------------------------------------------- | --------------------------- | +| GPT 6 Astra | gpt-6-astra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Fable 5.1 | claude-fable-5-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 5 | claude-sonnet-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.8 Flash | gemini-3.8-flash | `https://opencode.ai/zen/v1/models/gemini-3.8-flash` | `@ai-sdk/google` | +| Gemini 3.7 Flash | gemini-3.7-flash | `https://opencode.ai/zen/v1/models/gemini-3.7-flash` | `@ai-sdk/google` | +| Gemini 3.6 Flash | gemini-3.6-flash | `https://opencode.ai/zen/v1/models/gemini-3.6-flash` | `@ai-sdk/google` | +| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | +| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.3 | muse-spark-1.3 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.2 | muse-spark-1.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Vision Exp | deepseek-v4-flash-vision-exp | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.3 Flash | glm-5.3-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.3 | glm-5.3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling 3.0 Flash Fin Free | ling-3.0-flash-fin-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3.5 Lightning Free | nemotron-3.5-lightning-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Muse Spark 1.3 Contributor Free | muse-spark-1.3-contributor-free | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | + +[model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format +`opencode/`. Na primjer, za GPT 5.5 u konfiguraciji biste +koristili `opencode/gpt-5.5`. + +--- + +### Modeli + +Pun spisak dostupnih modela i njihovih metapodataka možete preuzeti na: + +``` +https://opencode.ai/zen/v1/models +``` + +--- + +## Cijene + +Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. + +| Model | Input | Output | Cached Read | Cached Write | +| --------------------------------- | ------ | ------- | ----------- | ------------ | +| Big Pickle | Free | Free | Free | - | +| MiMo-V2.5 Free | Free | Free | Free | - | +| Ling 3.0 Flash Fin Free | Free | Free | Free | - | +| Nemotron 3 Ultra Free | Free | Free | Free | - | +| Nemotron 3.5 Lightning Free | Free | Free | Free | - | +| Muse Spark 1.3 Contributor Free | Free | Free | Free | - | +| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | - | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - | +| GLM 5.3 Flash | $0.15 | $0.50 | $0.03 | - | +| GLM 5.3 | $1.40 | $4.40 | $0.26 | - | +| GLM 5.2 | $1.40 | $4.40 | $0.26 | - | +| GLM 5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM 5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.7 Plus | $0.40 | $1.60 | $0.04 | $0.50 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | +| DeepSeek V4 Flash Vision Exp | $0.14 | $0.28 | $0.028 | - | +| Claude Fable 5.1 | $10.00 | $50.00 | $0.25 | $12.50 | +| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Sonnet 5 | $2.00 | $10.00 | $0.20 | $2.50 | +| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | +| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 | +| Gemini 3.8 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.7 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.6 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.5 Flash | $1.50 | $9.00 | $0.15 | - | +| Gemini 3.5 Flash Lite | $0.30 | $2.50 | $0.03 | - | +| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | +| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | +| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | +| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | +| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | +| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - | +| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - | +| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | +| Muse Spark 1.3 | $1.25 | $4.25 | $0.15 | - | +| Muse Spark 1.2 | $1.25 | $4.25 | $0.15 | - | +| GPT 6 Astra (≤ 272K tokens) | $10.00 | $50.00 | $1.00 | $12.50 | +| GPT 6 Astra (> 272K tokens) | $20.00 | $75.00 | $2.00 | $25.00 | +| GPT 5.6 Sol (≤ 272K tokens) | $2.00 | $10.00 | $0.20 | $2.50 | +| GPT 5.6 Sol (> 272K tokens) | $4.00 | $15.00 | $0.40 | $5.00 | +| GPT 5.6 Terra (≤ 272K tokens) | $2.00 | $12.00 | $0.20 | $2.50 | +| GPT 5.6 Terra (> 272K tokens) | $4.00 | $18.00 | $0.40 | $5.00 | +| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | +| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | +| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | +| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | +| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | +| GPT 5.4 (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | - | +| GPT 5.4 (> 272K tokens) | $5.00 | $22.50 | $0.50 | - | +| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - | +| GPT 5.4 Mini | $0.75 | $4.50 | $0.075 | - | +| GPT 5.4 Nano | $0.20 | $1.25 | $0.02 | - | +| GPT 5.3 Codex Spark | $1.75 | $14.00 | $0.175 | - | +| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - | +| GPT 5.2 | $1.75 | $14.00 | $0.175 | - | +| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - | +| GPT 5.1 | $1.07 | $8.50 | $0.107 | - | +| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - | +| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - | +| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | +| GPT 5 | $1.07 | $8.50 | $0.107 | - | +| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | + +**GPT 5.6 Sol:** Prikazane cijene uključuju popust od 50% do 18. septembra 2026. + +**DeepSeek V4 Flash Vision Exp:** Slike se pretvaraju u tokene na osnovu svojih dimenzija i naplaćuju kao ulazni tokeni zajedno s tekstualnim tokenima. [Saznajte više](https://api-docs.deepseek.com/quick_start/pricing/). + +U historiji korištenja možete primijetiti [jeftinije modele](/docs/config/#models), kao što su Haiku, Nano ili Flash. OpenCode koristi ove modele za generisanje naslova sesija. + +:::note +Naknade za kreditne kartice prosljeđujemo po stvarnom trošku (4.4% + $0.30 po transakciji); ne naplaćujemo ništa preko toga. +::: + +Besplatni modeli: + +- MiMo-V2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Ling 3.0 Flash Fin Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Nemotron 3 Ultra Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Nemotron 3.5 Lightning Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Muse Spark 1.3 Contributor Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. + +Kontaktirajte nas ako imate bilo kakvih pitanja. + +--- + +### Auto-reload + +Ako vam stanje padne ispod $5, Zen će automatski dopuniti $20. + +Možete promijeniti iznos auto-reload dopune. Auto-reload možete i potpuno +isključiti. + +--- + +### Mjesečni limiti + +Možete postaviti mjesečni limit korištenja za cijeli workspace i za svakog člana +vašeg tima. + +Na primjer, recimo da postavite mjesečni limit korištenja na $20 — Zen neće +potrošiti više od $20 u toku mjeseca. Ali ako imate uključen auto-reload, Zen +vam ipak može naplatiti više od $20 ako vam stanje padne ispod $5. + +--- + +### Zastarjeli modeli + +| Model | Datum zastarijevanja | +| ------------------ | -------------------- | +| GPT 5.2 Codex | July 23, 2026 | +| GPT 5.1 Codex | July 23, 2026 | +| GPT 5.1 Codex Max | July 23, 2026 | +| GPT 5.1 Codex Mini | July 23, 2026 | +| GPT 5 Codex | July 23, 2026 | +| Claude Opus 4.1 | August 5, 2026 | +| Claude Sonnet 4 | June 15, 2026 | +| Claude Haiku 3.5 | February 16, 2026 | +| Gemini 3 Pro | March 9, 2026 | +| MiniMax M2.5 | August 5, 2026 | +| MiniMax M2.1 | March 15, 2026 | +| GLM 5 | May 14, 2026 | +| GLM 4.7 | March 15, 2026 | +| GLM 4.6 | March 15, 2026 | +| Kimi K2.5 | August 5, 2026 | +| Kimi K2 Thinking | March 6, 2026 | +| Kimi K2 | March 6, 2026 | +| Qwen3 Coder 480B | February 6, 2026 | + +--- + +## Privatnost + +Svi naši modeli su hostovani u US. Naši provajderi prate zero-retention politiku +i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: + +- Big Pickle: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- MiMo-V2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- Ling 3.0 Flash Fin Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- Nemotron 3 Ultra Free (besplatni NVIDIA endpointi): Samo za probnu upotrebu — nemojte slati lične ili povjerljive podatke. Vaše korištenje se bilježi radi sigurnosti i poboljšanja NVIDIA proizvoda i usluga. Zabilježeni podaci sesije koji se koriste u svrhu poboljšanja nisu povezani s vašim identitetom niti bilo kojim trajnim identifikatorom. Za više informacija o našim praksama obrade podataka pogledajte našu [Politiku privatnosti](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interakcijom s ovim endpointom pristajete na naše prikupljanje, bilježenje i korištenje takvih informacija te na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). +- Nemotron 3.5 Lightning Free (besplatni NVIDIA endpointi): Samo za probnu upotrebu — nemojte slati lične ili povjerljive podatke. Vaše korištenje se bilježi radi sigurnosti i poboljšanja NVIDIA proizvoda i usluga. Zabilježeni podaci sesije koji se koriste u svrhu poboljšanja nisu povezani s vašim identitetom niti bilo kojim trajnim identifikatorom. Za više informacija o našim praksama obrade podataka pogledajte našu [Politiku privatnosti](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interakcijom s ovim endpointom pristajete na naše prikupljanje, bilježenje i korištenje takvih informacija te na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). +- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). +- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). +- Muse Spark 1.3 Contributor Free: Znatno snižene cijene tokena u zamjenu za dozvolu da se vaši promptovi i odgovori koriste za treniranje budućih Meta modela. [Saznajte više](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier). + +--- + +## Za timove + +Zen odlično radi i za timove. Možete pozvati saigrače, dodijeliti uloge, birati +modele koje vaš tim koristi i još mnogo toga. + +:::note +Workspaces su trenutno besplatni za timove kao dio beta faze. +::: + +Upravljanje vašim workspace-om je trenutno besplatno za timove kao dio beta +faze. Uskoro ćemo podijeliti više detalja o cijenama. + +--- + +### Uloge + +Možete pozvati saigrače u svoj workspace i dodijeliti im uloge: + +- **Admin**: Upravlja modelima, članovima, API ključevima i naplatom +- **Member**: Upravlja samo vlastitim API ključevima + +Admini mogu postaviti i mjesečne limite potrošnje za svakog člana kako bi držali +troškove pod kontrolom. + +--- + +### Pristup modelima + +Admini mogu uključiti ili isključiti određene modele za workspace. Zahtjevi +poslani prema isključenom modelu vratiće grešku. + +Ovo je korisno u slučajevima kada želite onemogućiti korištenje modela koji +prikuplja podatke. + +--- + +### Donesite vlastiti ključ + +Možete koristiti vlastite OpenAI ili Anthropic API ključeve dok i dalje imate +pristup drugim modelima u Zen. + +Kada koristite vlastite ključeve, tokene direktno naplaćuje provajder, a ne Zen. + +Na primjer, vaša organizacija možda već ima OpenAI ili Anthropic ključ i želite +koristiti njega umjesto onog koji pruža Zen. + +--- + +## Ciljevi + +OpenCode Zen smo napravili da: + +1. **Benchmarkiramo** najbolje modele/provajdere za coding agente. +2. Imamo pristup opcijama **najvišeg kvaliteta** bez snižavanja performansi ili preusmjeravanja na jeftinije provajdere. +3. Prenesemo sva **sniženja cijena** prodajom po stvarnom trošku; tako da je jedini markup pokrivanje naših processing naknada. +4. Obezbijedimo **bez lock-ina** time što vam omogućavamo da ga koristite s bilo kojim drugim coding agentom. I da vam uvijek omogućimo da koristite bilo koji drugi provajder i u OpenCode. diff --git a/packages/web/src/content/docs/ru/commands.mdx b/packages/web/src/content/docs/ru/commands.mdx new file mode 100644 index 0000000000000000000000000000000000000000..447ef0f0dc6ede7d85a809127861ca4c313ba197 --- /dev/null +++ b/packages/web/src/content/docs/ru/commands.mdx @@ -0,0 +1,323 @@ +--- +title: Команды +description: Создавайте собственные команды для повторяющихся задач. +--- + +Пользовательские команды позволяют указать подсказку, которую вы хотите запускать при выполнении этой команды в TUI. + +```bash frame="none" +/my-command +``` + +Пользовательские команды дополняют встроенные команды, такие как `/init`, `/undo`, `/redo`, `/share`, `/help`. [Подробнее](/docs/tui#commands). + +--- + +## Создание файлов команд + +Создайте Markdown файлы в каталоге `commands/` для определения пользовательских команд. + +Создайте `.opencode/commands/test.md`: + +```md title=".opencode/commands/test.md" +--- +description: Run tests with coverage +agent: build +model: anthropic/claude-3-5-sonnet-20241022 +--- + +Run the full test suite with coverage report and show any failures. +Focus on the failing tests and suggest fixes. +``` + +Фронтматтер (frontmatter) определяет свойства команды. Содержимое становится шаблоном. + +Используйте команду, набрав `/`, а затем имя команды. + +```bash frame="none" +"/test" +``` + +--- + +## Настройка + +Вы можете добавлять собственные команды через конфигурацию opencode или создав файлы Markdown в каталоге `commands/`. + +--- + +### JSON + +Используйте опцию `command` в вашем opencode [config](/docs/config): + +```json title="opencode.jsonc" {4-12} +{ + "$schema": "https://opencode.ai/config.json", + "command": { + // This becomes the name of the command + "test": { + // This is the prompt that will be sent to the LLM + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.", + // This is shown as the description in the TUI + "description": "Run tests with coverage", + "agent": "build", + "model": "anthropic/claude-3-5-sonnet-20241022" + } + } +} +``` + +Теперь вы можете запустить эту команду в TUI: + +```bash frame="none" +/test +``` + +--- + +### Markdown + +Вы также можете определять команды, используя Markdown файлы. Поместите их в: + +- Глобальный: `~/.config/opencode/commands/` +- Для каждого проекта: `.opencode/commands/` + +```markdown title="~/.config/opencode/commands/test.md" +--- +description: Run tests with coverage +agent: build +model: anthropic/claude-3-5-sonnet-20241022 +--- + +Run the full test suite with coverage report and show any failures. +Focus on the failing tests and suggest fixes. +``` + +Имя Markdown файла становится именем команды. Например, `test.md` позволяет +вам запустить: + +```bash frame="none" +/test +``` + +--- + +## Настройка промпта + +Подсказки для пользовательских команд поддерживают несколько специальных заполнителей и синтаксиса. + +--- + +### Аргументы + +Передавайте аргументы командам, используя заполнитель `$ARGUMENTS`. + +```md title=".opencode/commands/component.md" +--- +description: Create a new component +--- + +Create a new React component named $ARGUMENTS with TypeScript support. +Include proper typing and basic structure. +``` + +Запустите команду с аргументами: + +```bash frame="none" +/component Button +``` + +И `$ARGUMENTS` будет заменен на `Button`. + +Вы также можете получить доступ к отдельным аргументам, используя позиционные параметры: + +- `$1` — первый аргумент +- `$2` — Второй аргумент +- `$3` — Третий аргумент +- И так далее... + +Например: + +```md title=".opencode/commands/create-file.md" +--- +description: Create a new file with content +--- + +Create a file named $1 in the directory $2 +with the following content: $3 +``` + +Запустите команду: + +```bash frame="none" +/create-file config.json src "{ \"key\": \"value\" }" +``` + +Это заменяет: + +- `$1` с `config.json` +- `$2` с `src` +- `$3` с `{ "key": "value" }` + +--- + +### Вывод shell + +Используйте _!`command`_, чтобы ввести вывод команды bash](/docs/tui#bash-commands) в приглашение. + +Например, чтобы создать пользовательскую команду, которая анализирует тестовое покрытие: + +```md title=".opencode/commands/analyze-coverage.md" +--- +description: Analyze test coverage +--- + +Here are the current test results: +!`npm test` + +Based on these results, suggest improvements to increase coverage. +``` + +Или просмотреть последние изменения: + +```md title=".opencode/commands/review-changes.md" +--- +description: Review recent changes +--- + +Recent git commits: +!`git log --oneline -10` + +Review these changes and suggest any improvements. +``` + +Команды выполняются в корневом каталоге вашего проекта, и их вывод становится частью приглашения. + +--- + +### Ссылки на файлы + +Включите файлы в свою команду, используя `@`, за которым следует имя файла. + +```md title=".opencode/commands/review-component.md" +--- +description: Review component +--- + +Review the component in @src/components/Button.tsx. +Check for performance issues and suggest improvements. +``` + +Содержимое файла автоматически включается в приглашение. + +--- + +## Параметры + +Рассмотрим варианты конфигурации подробнее. + +--- + +### Template + +Параметр `template` определяет приглашение, которое будет отправлено в LLM при выполнении команды. + +```json title="opencode.json" +{ + "command": { + "test": { + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes." + } + } +} +``` + +Это **обязательный** параметр конфигурации. + +--- + +### Описание + +Используйте опцию `description`, чтобы предоставить краткое описание того, что делает команда. + +```json title="opencode.json" +{ + "command": { + "test": { + "description": "Run tests with coverage" + } + } +} +``` + +Это отображается в виде описания в TUI при вводе команды. + +--- + +### Агент + +Используйте конфигурацию `agent`, чтобы дополнительно указать, какой [агент](/docs/agents) должен выполнить эту команду. +Если это [subagent](/docs/agents/#subagents), команда по умолчанию инициирует вызов субагента. +Чтобы отключить это поведение, установите для `subtask` значение `false`. + +```json title="opencode.json" +{ + "command": { + "review": { + "agent": "plan" + } + } +} +``` + +Это **необязательный** параметр конфигурации. Если не указано, по умолчанию используется текущий агент. + +--- + +### Subtask + +Используйте логическое значение `subtask`, чтобы заставить команду инициировать вызов [subagent](/docs/agents/#subagents). +Это полезно, если вы хотите, чтобы команда не загрязняла ваш основной контекст и **заставляла** агента действовать как субагент. +даже если для `mode` установлено значение `primary` в конфигурации [agent](/docs/agents). + +```json title="opencode.json" +{ + "command": { + "analyze": { + "subtask": true + } + } +} +``` + +Это **необязательный** параметр конфигурации. + +--- + +### Модель + +Используйте конфигурацию `model`, чтобы переопределить модель по умолчанию для этой команды. + +```json title="opencode.json" +{ + "command": { + "analyze": { + "model": "anthropic/claude-3-5-sonnet-20241022" + } + } +} +``` + +Это **необязательный** параметр конфигурации. + +--- + +## Встроенные команды + +opencode включает несколько встроенных команд, таких как `/init`, `/undo`, `/redo`, `/share`, `/help`; [подробнее](/docs/tui#commands). + +:::note +Пользовательские команды могут переопределять встроенные команды. +::: + +Если вы определите пользовательскую команду с тем же именем, она переопределит встроенную команду. diff --git a/packages/web/src/content/docs/ru/custom-tools.mdx b/packages/web/src/content/docs/ru/custom-tools.mdx new file mode 100644 index 0000000000000000000000000000000000000000..c487ee99585c50f2fa292d2b650baf67ba43baba --- /dev/null +++ b/packages/web/src/content/docs/ru/custom-tools.mdx @@ -0,0 +1,170 @@ +--- +title: Пользовательские инструменты +description: Создавайте инструменты, которые LLM может вызывать в opencode. +--- + +Пользовательские инструменты — это создаваемые вами функции, которые LLM может вызывать во время разговоров. Они работают вместе со [встроенными инструментами ](/docs/tools) opencode, такими как `read`, `write` и `bash`. + +--- + +## Создание инструмента + +Инструменты определяются как файлы **TypeScript** или **JavaScript**. Однако определение инструмента может вызывать сценарии, написанные на **любом языке** — TypeScript или JavaScript используются только для самого определения инструмента. + +--- + +### Расположение + +Их можно определить: + +- Локально, поместив их в каталог `.opencode/tools/` вашего проекта. +- Или глобально, поместив их в `~/.config/opencode/tools/`. + +--- + +### Структура + +Самый простой способ создания инструментов — использовать помощник `tool()`, который обеспечивает безопасность типов и проверку. + +```ts title=".opencode/tools/database.ts" {1} +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Query the project database", + args: { + query: tool.schema.string().describe("SQL query to execute"), + }, + async execute(args) { + // Your database logic here + return `Executed query: ${args.query}` + }, +}) +``` + +**имя файла** становится **именем инструмента**. Вышеупомянутое создает инструмент `database`. + +--- + +#### Несколько инструментов в файле + +Вы также можете экспортировать несколько инструментов из одного файла. Каждый экспорт становится **отдельным инструментом** с именем **`_`**: + +```ts title=".opencode/tools/math.ts" +import { tool } from "@opencode-ai/plugin" + +export const add = tool({ + description: "Add two numbers", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args) { + return args.a + args.b + }, +}) + +export const multiply = tool({ + description: "Multiply two numbers", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args) { + return args.a * args.b + }, +}) +``` + +При этом создаются два инструмента: `math_add` и `math_multiply`. + +--- + +### Аргументы + +Вы можете использовать `tool.schema`, то есть просто [Zod](https://zod.dev), для определения типов аргументов. + +```ts "tool.schema" +args: { + query: tool.schema.string().describe("SQL query to execute") +} +``` + +Вы также можете импортировать [Zod](https://zod.dev) напрямую и вернуть простой объект: + +```ts {6} +import { z } from "zod" + +export default { + description: "Tool description", + args: { + param: z.string().describe("Parameter description"), + }, + async execute(args, context) { + // Tool implementation + return "result" + }, +} +``` + +--- + +### Контекст + +Инструменты получают контекст текущего сеанса: + +```ts title=".opencode/tools/project.ts" {8} +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Get project information", + args: {}, + async execute(args, context) { + // Access context information + const { agent, sessionID, messageID, directory, worktree } = context + return `Agent: ${agent}, Session: ${sessionID}, Message: ${messageID}, Directory: ${directory}, Worktree: ${worktree}` + }, +}) +``` + +Используйте `context.directory` для рабочего каталога сеанса. +Используйте `context.worktree` для корня рабочего дерева git. + +--- + +## Примеры + +### Инструмент на Python + +Вы можете писать свои инструменты на любом языке, который захотите. Вот пример сложения двух чисел с использованием Python. + +Сначала создайте инструмент как скрипт Python: + +```python title=".opencode/tools/add.py" +import sys + +a = int(sys.argv[1]) +b = int(sys.argv[2]) +print(a + b) +``` + +Затем создайте определение инструмента, которое его вызывает: + +```ts title=".opencode/tools/python-add.ts" {10} +import { tool } from "@opencode-ai/plugin" +import path from "path" + +export default tool({ + description: "Add two numbers using Python", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args, context) { + const script = path.join(context.worktree, ".opencode/tools/add.py") + const result = await Bun.$`python3 ${script} ${args.a} ${args.b}`.text() + return result.trim() + }, +}) +``` + +Здесь мы используем утилиту [`Bun.$`](https://bun.com/docs/runtime/shell) для запуска скрипта Python. diff --git a/packages/web/src/content/docs/ru/ecosystem.mdx b/packages/web/src/content/docs/ru/ecosystem.mdx new file mode 100644 index 0000000000000000000000000000000000000000..81b0312bbb233c297200ede3ddadea711547ecea --- /dev/null +++ b/packages/web/src/content/docs/ru/ecosystem.mdx @@ -0,0 +1,78 @@ +--- +title: Экосистема +description: Проекты и интеграции, созданные с помощью OpenCode. +--- + +Коллекция проектов сообщества, построенных на OpenCode. + +:::note +Хотите добавить свой проект, связанный с OpenCode, в этот список? Разместите PR. +::: + +Вы также можете посетить [awesome-opencode](https://github.com/awesome-opencode/awesome-opencode) и [opencode.cafe](https://opencode.cafe) — хаб, объединяющий экосистему и сообщество. + +--- + +## Плагины + +| Имя | Описание | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| [opencode-daytona](https://github.com/daytonaio/daytona/tree/main/libs/opencode-plugin) | Автоматически запускайте сеансы OpenCode в изолированных песочницах Daytona с синхронизацией git и предварительным просмотром в реальном времени | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Автоматически внедрять заголовки сеансов Helicone для группировки запросов | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Автоматическое внедрение типов TypeScript/Svelte в файлы, считываемые с помощью инструментов поиска | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Используйте подписку ChatGPT Plus/Pro вместо кредитов API | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Используйте существующий план Gemini вместо выставления счетов через API | +| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Используйте бесплатные модели Antigravity вместо выставления счетов через API | +| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Многоветвевая изоляция контейнеров разработки с мелкими клонами и автоматическим назначением портов | +| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Плагин Google Antigravity OAuth с поддержкой поиска Google и более надежной обработкой API | +| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Оптимизируйте использование токенов за счет сокращения выходных данных устаревших инструментов | +| [opencode-vibeguard](https://github.com/inkdust2021/opencode-vibeguard) | Скрывайте секреты/PII, заменяя их плейсхолдерами в стиле VibeGuard перед отправкой в LLM; восстанавливайте локально | +| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Добавьте встроенную поддержку веб-поиска для поддерживаемых поставщиков в стиле Google | +| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Позволяет агентам ИИ запускать фоновые процессы в PTY и отправлять им интерактивные данные | +| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Инструкции для неинтерактивных shell-команд — предотвращают зависания из-за операций, зависящих от TTY | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Отслеживайте использование OpenCode с помощью Wakatime | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Очистка таблиц Markdown, созданных LLM | +| [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | Редактирование Fast Apply, поиск по кодовой базе WarpGrep и сжатие контекста через Morph | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Фоновые агенты, встроенные инструменты LSP/AST/MCP, курируемые агенты, совместимость с Claude Code | +| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Уведомления на рабочем столе и звуковые оповещения для сеансов OpenCode | +| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Уведомления на рабочем столе и звуковые оповещения о разрешениях, завершении и событиях ошибок | +| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | Автоматическое именование сеансов Zellij на основе искусственного интеллекта на основе контекста OpenCode | +| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Разрешить агентам OpenCode отложенную загрузку подсказок по требованию с обнаружением и внедрением навыков | +| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Постоянная память между сеансами с использованием Supermemory | +| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Интерактивный обзор плана с визуальными аннотациями и возможностью совместного использования в частном или автономном режиме | +| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Расширьте opencode/команды до мощной системы оркестровки с детальным управлением потоком данных | +| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Планируйте повторяющиеся задания с помощью launchd (Mac) или systemd (Linux) с синтаксисом cron | +| [micode](https://github.com/vtemian/micode) | Структурированный мозговой штурм → План → Реализация рабочего процесса с непрерывностью сеанса | +| [octto](https://github.com/vtemian/octto) | Интерактивный пользовательский интерфейс браузера для мозгового штурма с помощью искусственного интеллекта с формами из нескольких вопросов | +| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Фоновые агенты в стиле Claude Code с асинхронным делегированием и сохранением контекста | +| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Встроенные уведомления ОС для OpenCode – узнайте, когда задачи завершены | +| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Комплексный пакет многоагентной оркестровки — 16 компонентов, одна установка | +| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Рабочие деревья git с нулевым трением для OpenCode | +| [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Отслеживайте и отлаживайте ваших ИИ-агентов с помощью Sentry AI Monitoring | + +--- + +## Проекты + +| Имя | Описание | +| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| [kimaki](https://github.com/remorses/kimaki) | Discord-бот для управления сеансами OpenCode, созданный на базе SDK | +| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Плагин Neovim для подсказок с поддержкой редактора, созданный на основе API | +| [portal](https://github.com/hosenur/portal) | Мобильный веб-интерфейс для OpenCode через Tailscale/VPN | +| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Шаблон для создания плагинов OpenCode | +| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Интерфейс Neovim для OpenCode - агент кодирования искусственного интеллекта на базе терминала | +| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Поставщик Vercel AI SDK для использования OpenCode через @opencode-ai/sdk | +| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Веб-приложение или настольное приложение и расширение VS Code для OpenCode | +| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Плагин Obsidian, встраивающий OpenCode в пользовательский интерфейс Obsidian | +| [OpenWork](https://github.com/different-ai/openwork) | Альтернатива Claude Cowork с открытым исходным кодом на базе OpenCode | +| [ocx](https://github.com/kdcokenny/ocx) | Менеджер расширений OpenCode с переносимыми изолированными профилями | +| [CodeNomad](https://github.com/NeuralNomadsAI/CodeNomad) | Настольное, веб-, мобильное и удаленное клиентское приложение для OpenCode | + +--- + +## Агенты + +| Имя | Описание | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | +| [Agentic](https://github.com/Cluster444/agentic) | Модульные ИИ-агенты и команды для структурированной разработки | +| [opencode-agents](https://github.com/darrenhinde/opencode-agents) | Конфигурации, подсказки, агенты и плагины для улучшения рабочих процессов | diff --git a/packages/web/src/content/docs/ru/enterprise.mdx b/packages/web/src/content/docs/ru/enterprise.mdx new file mode 100644 index 0000000000000000000000000000000000000000..d5932e0e0c88743e080f58b9275bd65ebf4e5875 --- /dev/null +++ b/packages/web/src/content/docs/ru/enterprise.mdx @@ -0,0 +1,168 @@ +--- +title: Корпоративное использование +description: Безопасное использование opencode в вашей организации. +--- + +import config from "../../../../config.mjs" +export const email = `mailto:${config.email}` + +opencode Enterprise предназначен для организаций, которые хотят быть уверены, что их код и данные никогда не покинут инфраструктуру. Это можно сделать с помощью централизованной конфигурации, которая интегрируется с вашим единым входом и внутренним шлюзом AI. + +:::note +opencode не хранит ваш код или контекстные данные. +::: + +Чтобы начать работу с opencode Enterprise: + +1. Проведите испытание внутри своей команды. +2. **Свяжитесь с нами**, чтобы обсудить цены и варианты внедрения. + +--- + +## Пробная версия + +opencode имеет открытый исходный код и не хранит ваш код или контекстные данные, поэтому ваши разработчики могут просто [приступить к работе](/docs/) и провести пробную версию. + +--- + +### Обработка данных + +**opencode не хранит ваш код или контекстные данные.** Вся обработка происходит локально или посредством прямых вызовов API к вашему провайдеру ИИ. + +Это означает, что пока вы используете поставщика, которому доверяете, или внутреннего +Шлюз AI позволяет безопасно использовать opencode. + +Единственное предостережение — это дополнительная функция `/share`. + +--- + +#### Обмен беседами + +Если пользователь включает функцию `/share`, разговор и связанные с ним данные отправляются в службу, которую мы используем для размещения этих общих страниц на opencode.ai. + +В настоящее время данные передаются через периферийную сеть нашей CDN и кэшируются на периферии рядом с вашими пользователями. + +Мы рекомендуем вам отключить эту функцию для пробной версии. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled" +} +``` + +[Подробнее о совместном использовании](/docs/share). + +--- + +### Владение кодом + +**Вы являетесь владельцем всего кода, созданного opencode.** Никаких лицензионных ограничений или претензий на право собственности нет. + +--- + +## Цены + +Мы используем модель «на рабочее место» для opencode Enterprise. Если у вас есть собственный шлюз LLM, мы не взимаем плату за используемые токены. Для получения более подробной информации о ценах и вариантах реализации **свяжитесь с нами**. + +--- + +## Развертывание + +После завершения пробной версии и готовности использовать opencode на +вашей организации, вы можете **связаться с нами**, чтобы обсудить +цены и варианты реализации. + +--- + +### Центральная конфигурация + +Мы можем настроить opencode для использования единой центральной конфигурации для всей вашей организации. + +Эта централизованная конфигурация может интегрироваться с вашим поставщиком единого входа и гарантирует всем пользователям доступ только к вашему внутреннему шлюзу AI. + +--- + +### Интеграция SSO + +Через центральную конфигурацию opencode может интегрироваться с провайдером единого входа вашей организации для аутентификации. + +Это позволяет opencode получать учетные данные для вашего внутреннего шлюза AI через существующую систему управления идентификацией. + +--- + +### Внутренний шлюз AI + +Благодаря центральной конфигурации opencode также можно настроить на использование только вашего внутреннего шлюза AI. + +Вы также можете отключить всех других поставщиков ИИ, гарантируя, что все запросы будут проходить через утвержденную инфраструктуру вашей организации. + +--- + +### Самостоятельный хостинг + +Хотя мы рекомендуем отключить страницы общего доступа, чтобы гарантировать, что ваши данные никогда не покинут вашу организацию, мы также можем помочь вам самостоятельно разместить их в вашей инфраструктуре. + +В настоящее время это находится в нашей дорожной карте. Если вы заинтересованы, **дайте нам знать**. + +--- + +## Часто задаваемые вопросы + +

+Что такое opencode Enterprise? + +opencode Enterprise предназначен для организаций, которые хотят быть уверены, что их код и данные никогда не покинут инфраструктуру. Это можно сделать с помощью централизованной конфигурации, которая интегрируется с вашим единым входом и внутренним шлюзом AI. + +
+ +
+Как начать работу с opencode Enterprise? + +Просто начните с внутреннего испытания со своей командой. opencode по умолчанию не сохраняет ваш код или контекстные данные, что упрощает начало работы. + +Затем **свяжитесь с нами**, чтобы обсудить цены и варианты внедрения. + +
+ +
+Как работает корпоративное ценообразование? + +Мы предлагаем корпоративные цены за рабочее место. Если у вас есть собственный шлюз LLM, мы не взимаем плату за используемые токены. Для получения более подробной информации **свяжитесь с нами**, чтобы получить индивидуальное предложение, соответствующее потребностям вашей организации. + +
+ +
+Защищены ли мои данные с помощью opencode Enterprise? + +Да. opencode не хранит ваш код или контекстные данные. Вся обработка происходит локально или посредством прямых вызовов API вашего провайдера ИИ. Благодаря централизованной настройке и интеграции единого входа ваши данные остаются в безопасности в инфраструктуре вашей организации. + +
+ +
+Можем ли мы использовать собственный частный реестр NPM? + +opencode поддерживает частные реестры npm посредством встроенной поддержки файлов `.npmrc` Bun. Если ваша организация использует частный реестр, такой как JFrog Artifactory, Nexus или аналогичный, убедитесь, что разработчики прошли аутентификацию перед запуском opencode. + +Чтобы настроить аутентификацию с помощью вашего частного реестра: + +```bash +npm login --registry=https://your-company.jfrog.io/api/npm/npm-virtual/ +``` + +При этом создается `~/.npmrc` с данными аутентификации. opencode автоматически подхватит его. + +:::caution +Перед запуском opencode вы должны войти в частный реестр. +::: + +Альтернативно вы можете вручную настроить файл `.npmrc`: + +```bash title="~/.npmrc" +registry=https://your-company.jfrog.io/api/npm/npm-virtual/ +//your-company.jfrog.io/api/npm/npm-virtual/:_authToken=${NPM_AUTH_TOKEN} +``` + +Разработчики должны войти в частный реестр перед запуском opencode, чтобы гарантировать возможность установки пакетов из корпоративного реестра. + +
diff --git a/packages/web/src/content/docs/ru/github.mdx b/packages/web/src/content/docs/ru/github.mdx new file mode 100644 index 0000000000000000000000000000000000000000..131d4f66ddfaaedd369be04bec2efcb6a91b9f9f --- /dev/null +++ b/packages/web/src/content/docs/ru/github.mdx @@ -0,0 +1,321 @@ +--- +title: GitHub +description: Используйте opencode в задачах и пул-реквестах GitHub. +--- + +opencode интегрируется с вашим рабочим процессом GitHub. Упомяните `/opencode` или `/oc` в своем комментарии, и opencode выполнит задачи в вашем средстве выполнения действий GitHub. + +--- + +## Возможности + +- **Триаж задач (Issue Triage)**. Попросите opencode разобраться в проблеме и объяснить ее вам. +- **Исправление и реализация**. Попросите opencode исправить проблему или реализовать функцию. Он будет работать в новой ветке и создаст PR со всеми изменениями. +- **Безопасность**: opencode запускается внутри ваших GitHub Runners. + +--- + +## Установка + +Запустите следующую команду в проекте, который находится в репозитории GitHub: + +```bash +opencode github install +``` + +Это поможет вам установить приложение GitHub, создать рабочий процесс и настроить secrets (секреты). + +--- + +### Ручная настройка + +Или вы можете настроить его вручную. + +1. **Установите приложение GitHub** + + Перейдите на [**github.com/apps/opencode-agent**](https://github.com/apps/opencode-agent). Убедитесь, что он установлен в целевом репозитории. + +2. **Добавьте рабочий процесс** + + Добавьте следующий файл рабочего процесса в `.github/workflows/opencode.yml` в своем репозитории. Обязательно установите соответствующий `model` и необходимые ключи API в `env`. + + ```yml title=".github/workflows/opencode.yml" {24,26} + name: opencode + + on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + + jobs: + opencode: + if: | + contains(github.event.comment.body, '/oc') || + contains(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run OpenCode + uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + # share: true + # github_token: xxxx + ``` + +3. **Храните ключи API в секрете** + + В **настройках** вашей организации или проекта разверните **Секреты и переменные** слева и выберите **Действия**. И добавьте необходимые ключи API. + +--- + +## Настройка + +- `model`: модель для использования с opencode. Принимает формат `provider/model`. Это **обязательно**. +- `agent`: используемый агент. Должен быть основным агентом. Возвращается к `default_agent` из конфигурации или к `"build"`, если не найден. +- `share`: следует ли предоставлять общий доступ к сеансу opencode. По умолчанию **true** для общедоступных репозиториев. +- `prompt`: дополнительный настраиваемый запрос для переопределения поведения по умолчанию. Используйте это, чтобы настроить обработку запросов opencode. +- `token`: дополнительный токен доступа GitHub для выполнения таких операций, как создание комментариев, фиксация изменений и открытие запросов на включение. По умолчанию opencode использует токен доступа к установке из приложения opencode GitHub, поэтому фиксации, комментарии и запросы на включение отображаются как исходящие из приложения. + + Кроме того, вы можете использовать [встроенный `GITHUB_TOKEN`](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token) средства запуска действий GitHub без установки приложения opencode GitHub. Просто не забудьте предоставить необходимые разрешения в вашем рабочем процессе: + + ```yaml + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + ``` + + Вы также можете использовать [токены личного доступа](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)(PAT), если предпочитаете. + +--- + +## Поддерживаемые события + +opencode может быть запущен следующими событиями GitHub: + +| Тип события | Инициировано | Подробности | +| ----------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `issue_comment` | Комментарий к проблеме или PR | Упомяните `/opencode` или `/oc` в своем комментарии. opencode считывает контекст и может создавать ветки, открывать PR или отвечать. | +| `pull_request_review_comment` | Комментируйте конкретные строки кода в PR. | Упоминайте `/opencode` или `/oc` при просмотре кода. opencode получает путь к файлу, номера строк и контекст сравнения. | +| `issues` | Issue открыт или изменен | Автоматически запускать opencode при создании или изменении проблем. Требуется ввод `prompt`. | +| `pull_request` | PR открыт или обновлен | Автоматически запускать opencode при открытии, синхронизации или повторном открытии PR. Полезно для автоматических обзоров. | +| `schedule` | Расписание на основе Cron | Запускайте opencode по расписанию. Требуется ввод `prompt`. Вывод поступает в журналы и PR (комментариев нет). | +| `workflow_dispatch` | Ручной триггер из пользовательского интерфейса GitHub | Запускайте opencode по требованию на вкладке «Действия». Требуется ввод `prompt`. Вывод идет в логи и PR. | + +### Пример: Расписание + +Запускайте opencode по расписанию для выполнения автоматизированных задач: + +```yaml title=".github/workflows/opencode-scheduled.yml" +name: Scheduled OpenCode Task + +on: + schedule: + - cron: "0 9 * * 1" # Every Monday at 9am UTC + +jobs: + opencode: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run OpenCode + uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review the codebase for any TODO comments and create a summary. + If you find issues worth addressing, open an issue to track them. +``` + +Для запланированных событий вход `prompt` **обязателен**, поскольку нет комментария, из которого можно было бы извлечь инструкции. Запланированные рабочие процессы выполняются без пользовательского контекста для проверки разрешений, поэтому рабочий процесс должен предоставлять `contents: write` и `pull-requests: write`, если вы ожидаете, что opencode будет создавать ветки или PR. + +--- + +### Пример: Pull Request + +Автоматически просматривать PR при их открытии или обновлении: + +```yaml title=".github/workflows/opencode-review.yml" +name: opencode-review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + review: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + model: anthropic/claude-sonnet-4-20250514 + use_github_token: true + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements +``` + +Если для событий `pull_request` не указан `prompt`, opencode по умолчанию проверяет запрос на включение. + +--- + +### Пример: Сортировка Issue + +Автоматически сортируйте новые проблемы. В этом примере фильтруется аккаунты, созданные более 30 дней назад, чтобы уменьшить количество спама: + +```yaml title=".github/workflows/opencode-triage.yml" +name: Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Check account age + id: check + uses: actions/github-script@v7 + with: + script: | + const user = await github.rest.users.getByUsername({ + username: context.payload.issue.user.login + }); + const created = new Date(user.data.created_at); + const days = (Date.now() - created) / (1000 * 60 * 60 * 24); + return days >= 30; + result-encoding: string + + - uses: actions/checkout@v6 + if: steps.check.outputs.result == 'true' + with: + persist-credentials: false + + - uses: anomalyco/opencode/github@latest + if: steps.check.outputs.result == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review this issue. If there's a clear fix or relevant docs: + - Provide documentation links + - Add error handling guidance for code examples + Otherwise, do not comment. +``` + +Для событий `issues` вход `prompt` **обязателен**, поскольку нет комментария, из которого можно было бы извлечь инструкции. + +--- + +## Пользовательские промпты + +Переопределите приглашение по умолчанию, чтобы настроить поведение opencode для вашего рабочего процесса. + +```yaml title=".github/workflows/opencode.yml" +- uses: anomalyco/opencode/github@latest + with: + model: anthropic/claude-sonnet-4-5 + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements +``` + +Это полезно для обеспечения соблюдения конкретных критериев проверки, стандартов кодирования или приоритетных областей, имеющих отношение к вашему проекту. + +--- + +## Примеры + +Вот несколько примеров того, как вы можете использовать opencode в GitHub. + +- **Объяснение проблемы** + + Добавьте этот комментарий в выпуск GitHub. + + ``` + /opencode explain this issue + ``` + + opencode прочитает всю ветку, включая все комментарии, и ответит с четким объяснением. + +- **Исправление проблемы** + + В выпуске GitHub скажите: + + ``` + /opencode fix this + ``` + + А opencode создаст новую ветку, внедрит изменения и откроет PR с изменениями. + +- **Проверка Pull Request и внесение изменений** + + Оставьте следующий комментарий к PR на GitHub. + + ``` + Delete the attachment from S3 when the note is removed /oc + ``` + + opencode внедрит запрошенное изменение и зафиксирует его в том же PR. + +- **Проверка отдельных строк кода** + + Оставляйте комментарии непосредственно к строкам кода на вкладке «Файлы» PR. opencode автоматически определяет файл, номера строк и контекст различий, чтобы предоставить точные ответы. + + ``` + [Comment on specific lines in Files tab] + /oc add error handling here + ``` + + При комментировании определенных строк opencode получает: + - Точный файл, который просматривается + - Конкретные строки кода + - Окружающий контекст различий + - Информация о номере строки + + Это позволяет выполнять более целевые запросы без необходимости вручную указывать пути к файлам или номера строк. diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx new file mode 100644 index 0000000000000000000000000000000000000000..eaa63ba96f92ee92e191397323c5d1ef5a05f6f9 --- /dev/null +++ b/packages/web/src/content/docs/ru/go.mdx @@ -0,0 +1,381 @@ +--- +title: Go +description: Недорогая подписка на открытые модели для программирования. +--- + +import config from "../../../../config.mjs" +export const console = config.console +export const email = `mailto:${config.email}` + +OpenCode Go — это недорогая подписка за **$10 в месяц**, которая предоставляет надежный доступ к популярным открытым моделям для программирования. + +Go работает так же, как и любой другой провайдер в OpenCode. Вы оформляете подписку на OpenCode Go и +получаете свой API-ключ. Использование Go **абсолютно необязательно**, и вам не нужно использовать его, чтобы +пользоваться OpenCode. + +Она предназначена прежде всего для международных пользователей и обеспечивает стабильный доступ по всему миру. + +--- + +## Предпосылки + +Открытые модели стали действительно хороши. Сейчас они достигают производительности, близкой к +проприетарным моделям для задач программирования. И поскольку многие провайдеры могут предоставлять к ним доступ +на конкурентных условиях, они, как правило, обходятся гораздо дешевле. + +Однако получить к ним надежный доступ с низкой задержкой может быть непросто. Провайдеры +различаются по качеству и доступности. + +:::tip +Мы протестировали выбранную группу моделей и провайдеров, которые хорошо работают с OpenCode. +::: + +Чтобы исправить это, мы сделали пару вещей: + +1. Мы протестировали выбранную группу открытых моделей и поговорили с их командами о том, как + лучше всего их запускать. +2. Затем мы поработали с несколькими провайдерами, чтобы убедиться, что они предоставляются + корректно. +3. Наконец, мы провели бенчмаркинг комбинаций модель/провайдер и составили + список, который мы смело можем рекомендовать. + +OpenCode Go дает вам доступ к этим моделям за **$10 в месяц**. + +--- + +## Как это работает + +OpenCode Go работает так же, как и любой другой провайдер в OpenCode. + +1. Вы входите в **OpenCode Zen**, подписываетесь на Go и + копируете свой API-ключ. +2. Вы выполняете команду `/connect` в TUI, выбираете `OpenCode Go` и вставляете + свой API-ключ. +3. Запустите `/models` в TUI, чтобы увидеть список моделей, доступных через Go. + +:::note +Только один участник рабочего пространства может подписаться на OpenCode Go. +::: + +Текущий список моделей включает: + +- **Grok 4.6** +- **GLM-5.3-Flash** +- **GLM-5.3** +- **GLM-5.2** +- **GLM-5.1** +- **GPT 5.6 Luna** +- **Kimi K3** +- **Kimi K2.7 Code** +- **Kimi K2.6** +- **LongCat-2.0** +- **MiMo-V2.5** +- **MiMo-V2.5-Pro** +- **MiniMax M3** +- **MiniMax M2.7** +- **Muse Spark 1.3 Contributor** ([доступно в отдельных регионах](https://ai.developer.meta.com/legal/geographic-use-policy)) +- **Muse Spark 1.2 Contributor** ([доступно в отдельных регионах](https://ai.developer.meta.com/legal/geographic-use-policy)) +- **Qwen3.8 Max** +- **Qwen3.8 Flash** +- **Qwen3.7 Max** +- **Qwen3.7 Plus** +- **Qwen3.6 Plus** +- **DeepSeek V4.1 Flash** +- **DeepSeek V4 Pro** +- **DeepSeek V4 Flash** +- **DeepSeek V4 Flash Vision Exp** +- **Hy4 preview** +- **Hy3** + +Список моделей может меняться по мере того, как мы тестируем и добавляем новые. + +--- + +## Где можно использовать OpenCode Go? + +OpenCode Go предназначен для [OpenCode](https://opencode.ai) и других агентов для программирования, +которые отправляют запросы схожих типов. Трафик отслеживается для выявления злоупотреблений, +ухудшающих работу сервиса для других пользователей. + +Ваш клиент должен: + +1. Отправлять трафик, типичный для агента программирования. +2. Идентифицировать себя с помощью собственного user agent, например `my-coding-agent/1.0`, а не + универсального названия SDK или HTTP-библиотеки. +3. Отправлять стабильный идентификатор сессии в заголовке `x-opencode-session` для каждого диалога, чтобы мы могли оптимизировать маршрутизацию и + кеширование промптов. + +### Проверенные клиенты + +Помимо OpenCode, корректная работа с OpenCode Go подтверждена для следующих клиентов. +Однако мы не гарантируем, что они продолжат работать в будущем. + +| Клиент | Поддержка сессий | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Hermes** | Сборки, содержащие [PR #101864](https://github.com/NousResearch/hermes-agent/pull/101864), отправляют заголовок в основных и вспомогательных запросах OpenCode. Исправление было объединено после v0.21.0; само это издание его не содержит. | +| **Claude Code** | Go распознает его нативный заголовок сессии. Обертка для добавления пользовательского заголовка не требуется. | +| **Codex** | Go распознает его нативный заголовок сессии. Некоторые версии и конфигурации прокси по-прежнему его не передают; сохраняйте заголовок сессии при пересылке запросов. | +| **ZCode** | Go распознает его нативный заголовок сессии. Наш [запрос на `x-opencode-session`](https://github.com/zai-org/feedback/issues/492) остается открытым, но отправлять именно этот заголовок больше не требуется. | +| **Pi** | Текущие сборки отправляют информацию о сессии для OpenCode. Обновите более старые установки. | +| **jcode** | Обновитесь до версии **v0.81.6 или новее**, которая содержит [исправление заголовка сессии](https://github.com/1jehuang/jcode/issues/1167). | +| **Kilo Code CLI** | Сборки, содержащие [PR #13752](https://github.com/Kilo-Org/kilocode/pull/13752), восстанавливают заголовки сессии OpenCode. Это исправление относится к CLI, но не к расширению VS Code. См. [issue #13723](https://github.com/Kilo-Org/kilocode/issues/13723). | + +### Клиенты с известными проблемами + +В исследованных нами версиях этих клиентов поддержка сессий отсутствует или реализована +не полностью. По ссылкам можно отслеживать исправления и обходные решения. + +| Клиент | Статус и отслеживание | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **DeepSeek Harness** | Информация о сессии поступает для некоторых путей моделей, но отсутствует для других. Мы распознаем его нативный заголовок; остается обеспечить его отправку через все адаптеры. [Discussion #5495](https://github.com/deepseek-ai/deepseek-harness/discussions/5495). | +| **GitHub Copilot Chat** | Запрос на автоматическую поддержку заголовка сессии создан в [VS Code issue #334186](https://github.com/microsoft/vscode/issues/334186). | +| **Kimi Code** | Запрос на автоматическую поддержку заголовка сессии создан в [issue #3506](https://github.com/MoonshotAI/kimi-code/issues/3506). | +| **MiMo Code** | Для [issue #2317](https://github.com/XiaomiMiMo/MiMo-Code/issues/2317) предложено исправление в [PR #2327](https://github.com/XiaomiMiMo/MiMo-Code/pull/2327), который еще не объединен. | + +## Лимиты использования + +Лимиты использования определяются как месячные суммы в долларах. В таблице ниже указаны месячный лимит и стоимость токенов для каждой модели. + +Для каждой модели действуют следующие лимиты использования: на 5 часов — 20% месячного лимита; на неделю — 50%; на месяц — 100%. + +Например, если месячный лимит модели составляет $60, вы можете потратить до: + +- **Лимит на 5 часов** — использование на сумму $12 +- **Недельный лимит** — использование на сумму $30 +- **Месячный лимит** — использование на сумму $60 + +Цены на токены указаны за 1M токенов. + +| Model | Input | Output | Cached Read | Cached Write | Месячный лимит | +| --------------------------------------- | ------ | ------ | ----------- | ------------ | ------------------------------------------------------- | +| GLM-5.3-Flash | $0.15 | $0.50 | $0.03 | - | **$60** | +| GLM-5.3 | $1.40 | $4.40 | $0.26 | - | **$15** | +| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | **$60** | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | **$60** | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | **$15** | +| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | **$60** | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | **$60** | +| LongCat-2.0 | $0.30 | $1.20 | $0.006 | - | **$60** | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | **$60** | +| MiMo V2.5 Pro | $0.435 | $0.87 | $0.003625 | - | **$15** | +| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | **$60** | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** | +| Muse Spark 1.3 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** | +| Muse Spark 1.2 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** | +| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | **$15** | +| Qwen3.8 Flash | $0.15 | $0.47 | $0.016 | $0.20 | **$30** | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | **$30** | +| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 | **$60** | +| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 | **$60** | +| Qwen3.6 Plus (≤ 256K tokens) | $0.50 | $3.00 | $0.05 | $0.625 | **$60** | +| Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | **$60** | +| DeepSeek V4.1 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | ~~$15~~ **$60**
4x · До 20 сентября | +| DeepSeek V4.1 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | ~~$15~~ **$60**
4x · До 20 сентября | +| DeepSeek V4 Pro (Off-Peak) | $0.66 | $1.98 | $0.022 | - | **$15** | +| DeepSeek V4 Pro (Peak) | $1.32 | $3.96 | $0.044 | - | **$15** | +| DeepSeek V4 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$30** | +| DeepSeek V4 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$30** | +| DeepSeek V4 Flash Vision Exp (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$15** | +| DeepSeek V4 Flash Vision Exp (Peak) | $0.30 | $1.20 | $0.006 | - | **$15** | +| Hy4 preview | $0.834 | $2.501 | $0.042 | - | **$30** | +| Hy3 | $0.14 | $0.58 | $0.035 | - | **$60** | +| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** | +| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** | +| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$15** | +| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | **$15** | + +**DeepSeek V4.1 Flash / V4 Pro / V4 Flash / V4 Flash Vision Exp:** Часы Peak с понедельника по пятницу: 01:00-04:00 и 06:00-10:00 UTC; все остальные часы, включая выходные, относятся к Off-Peak. [Подробнее](https://api-docs.deepseek.com/quick_start/pricing/). + +**DeepSeek V4 Flash Vision Exp:** Изображения преобразуются в токены с учётом их размеров и оплачиваются как входные токены вместе с текстовыми токенами. [Подробнее](https://api-docs.deepseek.com/quick_start/pricing/). + +### Примерное число запросов + +В таблице ниже приведено примерное число запросов на основе типичных сценариев использования Go: + +| Model | запросов за 5 часов | запросов в неделю | запросов в месяц | +| ----------------------------------------------------------- | ------------------------- | -------------------------- | --------------------------- | +| GLM-5.3-Flash | 6,320 | 15,790 | 31,580 | +| GLM-5.3 | 220 | 540 | 1,080 | +| GLM-5.2 | 880 | 2,150 | 4,300 | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| Kimi K3 | 110 | 250 | 490 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | +| Kimi K2.6 | 1,150 | 2,880 | 5,750 | +| LongCat-2.0 | 11,400 | 28,600 | 57,200 | +| MiMo-V2.5 | 30,100 | 75,200 | 150,400 | +| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 3,200 | 8,000 | 16,000 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| Muse Spark 1.3 Contributor | 45,300 | 113,300 | 226,600 | +| Muse Spark 1.2 Contributor | 45,300 | 113,300 | 226,600 | +| Qwen3.8 Max | 160 | 400 | 810 | +| Qwen3.8 Flash | 5,400 | 13,500 | 27,000 | +| Qwen3.7 Max | 170 | 420 | 840 | +| Qwen3.7 Plus | 4,300 | 10,800 | 21,600 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| DeepSeek V4.1 Flash
4x · До 20 сентября | ~~6,500~~
**26,000** | ~~16,250~~
**65,000** | ~~32,500~~
**130,000** | +| DeepSeek V4 Pro | 1,050 | 2,600 | 5,200 | +| DeepSeek V4 Flash | 13,000 | 32,500 | 65,000 | +| DeepSeek V4 Flash Vision Exp | 6,500 | 16,250 | 32,500 | +| Hy4 preview | 1,350 | 3,380 | 6,770 | +| Hy3 | 4,300 | 10,750 | 21,500 | +| Grok 4.6 | 169 | 423 | 845 | +| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 | + +В оценках используются следующие количества токенов на запрос; фактическое использование может отличаться. + +- Grok 4.6 — 390 входных, 32,500 кешированных, 120 выходных токенов на запрос +- GLM-5.3-Flash — 1,000 входных, 55,000 кешированных, 200 выходных токенов на запрос +- GLM-5.3/5.2/5.1 — 700 входных, 52,000 кешированных, 150 выходных токенов на запрос +- GPT 5.6 Luna — 1,000 входных, 50,000 кешированных, 220 выходных токенов на запрос +- Kimi K3 — 1,050 входных, 76,500 кешированных, 300 выходных токенов на запрос +- Kimi K2.7/K2.6 — 870 входных, 55,000 кешированных, 200 выходных токенов на запрос +- LongCat-2.0 — 920 входных, 88,900 кешированных, 200 выходных токенов на запрос +- DeepSeek V4.1 Flash — 410 входных, 71,300 кешированных, 310 выходных токенов на запрос +- DeepSeek V4 Pro — 750 входных, 82,000 кешированных, 290 выходных токенов на запрос +- DeepSeek V4 Flash — 410 входных, 71,300 кешированных, 310 выходных токенов на запрос +- DeepSeek V4 Flash Vision Exp — 410 входных, 71,300 кешированных, 310 выходных токенов на запрос +- MiniMax M3 — 510 входных, 56,000 кешированных, 190 выходных токенов на запрос +- MiniMax M2.7 — 300 входных, 55,000 кешированных, 125 выходных токенов на запрос +- Muse Spark 1.3 Contributor — 620 входных, 71,400 кешированных, 300 выходных токенов на запрос +- Muse Spark 1.2 Contributor — 620 входных, 71,400 кешированных, 300 выходных токенов на запрос +- Qwen3.8 Max — 420 входных, 66,000 кешированных, 200 выходных токенов на запрос +- Qwen3.8 Flash — 600 входных, 58,000 кешированных, 200 выходных токенов на запрос +- Qwen3.7 Max — 420 входных, 66,000 кешированных, 200 выходных токенов на запрос +- Qwen3.7 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос +- Qwen3.6 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос +- Hy4 preview — 830 входных, 71,500 кешированных, 295 выходных токенов на запрос +- Hy3 — 830 входных, 71,500 кешированных, 295 выходных токенов на запрос +- MiMo-V2.5 — 830 входных, 71,500 кешированных, 295 выходных токенов на запрос +- MiMo-V2.5-Pro — 790 входных, 86,000 кешированных, 305 выходных токенов на запрос + +Вы можете отслеживать текущее использование в **консоли**. + +:::tip +Если вы достигнете лимита использования, вы можете продолжить использовать бесплатные модели. +::: + +Лимиты использования могут измениться по мере того, как мы будем собирать данные о раннем использовании и отзывы. + +--- + +### Использование сверх лимитов + +Если у вас также есть средства на балансе Zen, вы можете включить опцию **Use balance** +в консоли. Если она включена, Go будет использовать ваш баланс Zen +после достижения лимитов использования вместо того, чтобы блокировать запросы. + +--- + +### Почему для некоторых моделей доступен меньший объем использования + +С Go вы платите $10 в месяц, а включённый объём использования за месяц зависит от модели. + +Для большинства моделей это возможно благодаря оптовым скидкам и зарезервированным мощностям GPU. Полученную экономию мы передаем вам в виде большего объёма использования за месяц. + +Для некоторых моделей у нас еще не было возможности договориться о скидке или развернуть их с меньшими затратами: либо модель новая, либо ее публичные тарифы уже включают скидку. + +Для этих моделей вы все равно получаете немного больше, чем при прямой оплате их провайдерам; именно поэтому включённый для них объём использования за месяц меньше. + +--- + +## Эндпоинты + +Вы также можете получить доступ к моделям Go через следующие API-эндпоинты. + +| Модель | ID модели | Эндпоинт | Пакет AI SDK | +| ---------------------------- | ---------------------------- | ------------------------------------------------ | --------------------------- | +| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| GLM-5.3-Flash | glm-5.3-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.3 | glm-5.3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.2 | glm-5.2 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.1 | glm-5.1 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| LongCat-2.0 | longcat-2.0 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4.1 Flash | deepseek-v4.1-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Vision Exp | deepseek-v4-flash-vision-exp | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Muse Spark 1.3 Contributor | muse-spark-1.3-contributor | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.2 Contributor | muse-spark-1.2-contributor | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| Qwen3.8 Max | qwen3.8-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.8 Flash | qwen3.8-flash | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy4 preview | hy4-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | + +[ID модели](/docs/config/#models) в вашем конфиге OpenCode +использует формат `opencode-go/`. Например, для Kimi K3 вам нужно +использовать `opencode-go/kimi-k3` в вашем конфиге. + +--- + +### Модели + +Вы можете получить полный список доступных моделей и их метаданных по адресу: + +``` +https://opencode.ai/zen/go/v1/models +``` + +--- + +## Конфиденциальность + +| Модель | Обучение моделей | Хранение данных | +| ---------------------------- | ---------------- | --------------- | +| Grok 4.6 | Не используется | 30 дней | +| GPT 5.6 Luna | Не используется | 30 дней | +| GLM-5.3-Flash | Не используется | 0 дней | +| GLM-5.3 | Не используется | 0 дней | +| GLM-5.2 | Не используется | 0 дней | +| GLM-5.1 | Не используется | 0 дней | +| Kimi K3 | Не используется | 0 дней | +| Kimi K2.7 Code | Не используется | 0 дней | +| Kimi K2.6 | Не используется | 0 дней | +| LongCat-2.0 | Не используется | 0 дней | +| MiMo-V2.5-Pro | Не используется | 0 дней | +| MiMo-V2.5 | Не используется | 0 дней | +| Qwen3.8 Max | Не используется | 0 дней | +| Qwen3.8 Flash | Не используется | 0 дней | +| Qwen3.7 Max | Не используется | 0 дней | +| Qwen3.7 Plus | Не используется | 0 дней | +| Qwen3.6 Plus | Не используется | 0 дней | +| MiniMax M3 | Не используется | 0 дней | +| MiniMax M2.7 | Не используется | 0 дней | +| Muse Spark 1.3 Contributor | Да | Не ZDR | +| Muse Spark 1.2 Contributor | Да | Не ZDR | +| DeepSeek V4.1 Flash | Не используется | 0 дней | +| DeepSeek V4 Pro | Не используется | 0 дней | +| DeepSeek V4 Flash | Не используется | 0 дней | +| DeepSeek V4 Flash Vision Exp | Не используется | 0 дней | +| Hy4 preview | Не используется | 0 дней | +| Hy3 | Не используется | 0 дней | + +- **Grok 4.6:** ZDR отключает важные функции API, зависящие от сохраненных данных, включая Responses API с сохранением состояния, Files and Collections и Batch API. [Подробнее](https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr). +- **GPT 5.6 Luna:** Журналы мониторинга злоупотреблений создаются при любом использовании функций API и хранятся до 30 дней. [Подробнее](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring). +- **Muse Spark 1.3 Contributor:** Значительно сниженная стоимость токенов в обмен на разрешение использовать ваши промпты и ответы для обучения будущих моделей Meta. Доступность ограничена регионами, разрешёнными [Политикой географического использования](https://ai.developer.meta.com/legal/geographic-use-policy) компании Meta. [Подробнее](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier). +- **Muse Spark 1.2 Contributor:** Значительно сниженная стоимость токенов в обмен на разрешение использовать ваши промпты и ответы для обучения будущих моделей Meta. Доступность ограничена регионами, разрешёнными [Политикой географического использования](https://ai.developer.meta.com/legal/geographic-use-policy) компании Meta. [Подробнее](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier). +- **DeepSeek:** Соглашение ZDR продлевается ежемесячно. Текущее соглашение действует до 30 сентября 2026 года. + +--- + +## Цели + +Мы создали OpenCode Go, чтобы: + +1. Сделать программирование с ИИ **доступным** для большего числа людей с помощью недорогой подписки. +2. Обеспечить **надежный** доступ к лучшим открытым моделям для программирования. +3. Отбирать модели, которые **протестированы и проверены** для использования в качестве агентов-программистов. +4. Избежать **привязки к провайдеру**, позволяя вам также использовать любого другого провайдера вместе с OpenCode. diff --git a/packages/web/src/content/docs/ru/ide.mdx b/packages/web/src/content/docs/ru/ide.mdx new file mode 100644 index 0000000000000000000000000000000000000000..8691767e3d4ff663628126daee42271b203e03b3 --- /dev/null +++ b/packages/web/src/content/docs/ru/ide.mdx @@ -0,0 +1,48 @@ +--- +title: IDE +description: Расширение opencode для VS Code, Cursor и других IDE. +--- + +opencode интегрируется с VS Code, Cursor или любой IDE, поддерживающей терминал. Просто запустите `opencode` в терминале, чтобы начать. + +--- + +## Использование + +- **Быстрый запуск**: используйте `Cmd+Esc` (Mac) или `Ctrl+Esc` (Windows/Linux), чтобы открыть opencode в разделенном представлении терминала, или сфокусируйтесь на существующем сеансе терминала, если он уже запущен. +- **Новый сеанс**: используйте `Cmd+Shift+Esc` (Mac) или `Ctrl+Shift+Esc` (Windows/Linux), чтобы начать новый сеанс терминала opencode, даже если он уже открыт. Вы также можете нажать кнопку opencode в пользовательском интерфейсе. +- **Осведомленность о контексте**: автоматически делитесь своим текущим выбором или вкладкой с помощью opencode. +- **Шорткаты ссылок на файлы**: Используйте `Cmd+Option+K` (Mac) или `Alt+Ctrl+K` (Linux/Windows) для вставки ссылок на файлы. Например, `@File#L37-42`. + +--- + +## Установка + +Чтобы установить opencode на VS Code и популярные форки, такие как Cursor, Windsurf, VSCodium: + +1. Откройте VS Code +2. Откройте встроенный терминал +3. Запустите `opencode` — расширение установится автоматически. + +С другой стороны, если вы хотите использовать собственную IDE при запуске `/editor` или `/export` из TUI, вам необходимо установить `export EDITOR="code --wait"`. [Подробнее](/docs/tui/#editor-setup). + +--- + +### Ручная установка + +Найдите **opencode** в магазине расширений и нажмите **Установить**. + +--- + +### Устранение неполадок + +Если расширение не устанавливается автоматически: + +- Убедитесь, что вы используете `opencode` во встроенном терминале. +- Убедитесь, что CLI для вашей IDE установлен: + - Для Code: команда `code`. + - Для Cursor: команда `cursor`. + - Для Windsurf: команда `windsurf`. + - Для VSCodium: команда `codium`. + - Если нет, запустите `Cmd+Shift+P` (Mac) или `Ctrl+Shift+P` (Windows/Linux) и найдите "Shell Command: Install 'code' command in PATH" (или эквивалент для вашей IDE). +- Убедитесь, что у VS Code есть разрешение на установку расширений. diff --git a/packages/web/src/content/docs/ru/index.mdx b/packages/web/src/content/docs/ru/index.mdx new file mode 100644 index 0000000000000000000000000000000000000000..3208b32b5e00fc2d45583090a1b229234e51e958 --- /dev/null +++ b/packages/web/src/content/docs/ru/index.mdx @@ -0,0 +1,356 @@ +--- +title: Введение +description: Начните работу с opencode. +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" +import config from "../../../../config.mjs" +export const console = config.console + +[**opencode**](/) — это агент кодирования искусственного интеллекта с открытым исходным кодом. Он доступен в виде интерфейса на базе терминала, настольного приложения или расширения IDE. + +![opencode TUI с темой opencode](../../../assets/lander/screenshot.png) + +Давайте начнем. + +--- + +#### Системные требования + +Чтобы использовать opencode в вашем терминале, вам понадобится: + +1. Современный эмулятор терминала, например: + - [WezTerm](https://wezterm.org), кроссплатформенный + - [Alacritty](https://alacritty.org), кроссплатформенный + - [Ghostty](https://ghostty.org), Linux и macOS + - [Kitty](https://sw.kovidgoyal.net/kitty/), Linux и macOS + +2. Ключи API для поставщиков LLM, которых вы хотите использовать. + +--- + +## Установка + +Самый простой способ установить opencode — через сценарий установки. + +```bash +curl -fsSL https://opencode.ai/install | bash +``` + +Вы также можете установить его с помощью следующих команд: + +- **Использование Node.js** + + + + + ```bash + npm install -g opencode-ai + ``` + + + + + ```bash + bun install -g opencode-ai + ``` + + + + + ```bash + pnpm install -g opencode-ai + ``` + + + + + ```bash + yarn global add opencode-ai + ``` + + + + + +- **Использование Homebrew в macOS и Linux** + + ```bash + brew install anomalyco/tap/opencode + ``` + + > Мы рекомендуем использовать tap opencode для получения самых последних версий. Официальная формула `brew install opencode` поддерживается командой Homebrew и обновляется реже. + +- **Использование Paru в Arch Linux** + + ```bash + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) + ``` + +#### Windows + +:::tip[Рекомендуется: используйте WSL] +Для наилучшей работы в Windows мы рекомендуем использовать [Подсистема Windows для Linux (WSL)](/docs/windows-wsl). Он обеспечивает лучшую производительность и полную совместимость с функциями opencode. +::: + +- **Используя Chocolatey** + + ```bash + choco install opencode + ``` + +- **Использование Scoop** + + ```bash + scoop install opencode + ``` + +- **Использование NPM** + + ```bash + npm install -g opencode-ai + ``` + +- **Использование Mise** + + ```bash + mise use -g github:anomalyco/opencode + ``` + +- **Использование Docker** + + ```bash + docker run -it --rm ghcr.io/anomalyco/opencode + ``` + +В настоящее время добавляется поддержка установки opencode в Windows с помощью Bun. + +Вы также можете получить бинарный файл в разделе [Releases](https://github.com/anomalyco/opencode/releases). + +--- + +## Настроить + +С opencode вы можете использовать любого поставщика LLM, настроив его ключи API. + +Если вы новичок в использовании поставщиков LLM, мы рекомендуем использовать [OpenCode Zen](/docs/zen). +Это тщательно подобранный список моделей, протестированных и проверенных opencode. + +1. Запустите команду `/connect` в TUI, выберите opencode и перейдите по адресу [opencode.ai/auth](https://opencode.ai/auth). + + ```txt + /connect + ``` + +2. Войдите в систему, добавьте свои платежные данные и скопируйте ключ API. + +3. Вставьте свой ключ API. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +Альтернативно вы можете выбрать одного из других поставщиков. [Подробнее](/docs/providers#directory). + +--- + +## Инициализация + +Теперь, когда вы настроили поставщика, вы можете перейти к проекту, который +над которым вы хотите работать. + +```bash +cd /path/to/project +``` + +И запустите opencode. + +```bash +opencode +``` + +Затем инициализируйте opencode для проекта, выполнив следующую команду. + +```bash frame="none" +/init +``` + +Это позволит opencode проанализировать ваш проект и создать файл `AGENTS.md` в +корень проекта. + +:::tip +Вам следует зафиксировать файл `AGENTS.md` вашего проекта в Git. +::: + +Это помогает opencode понять структуру проекта и шаблоны кодирования. +использовал. + +--- + +## Использование + +Теперь вы готовы использовать opencode для работы над своим проектом. Не стесняйтесь спрашивать о чем угодно! + +Если вы новичок в использовании агента кодирования ИИ, вот несколько примеров, которые могут вам помочь. + +--- + +### Задавайте вопросы + +Вы можете попросить opencode объяснить вам кодовую базу. + +:::tip +Используйте ключ `@` для нечеткого поиска файлов в проекте. +::: + +```txt frame="none" "@packages/functions/src/api/index.ts" +How is authentication handled in @packages/functions/src/api/index.ts +``` + +Это полезно, если есть часть кодовой базы, над которой вы не работали. + +--- + +### Добавление функций + +Вы можете попросить opencode добавить новые функции в ваш проект. Хотя мы сначала рекомендуем попросить его создать план. + +1. **Составьте план** + + opencode имеет _режим планирования_, который отключает возможность вносить изменения и + вместо этого предложите _как_ реализовать эту функцию. + + Переключитесь на него с помощью клавиши **Tab**. Вы увидите индикатор этого в правом нижнем углу. + + ```bash frame="none" title="Switch to Plan mode" + + ``` + + Теперь давайте опишем, что мы хотим от него. + + ```txt frame="none" + When a user deletes a note, we'd like to flag it as deleted in the database. + Then create a screen that shows all the recently deleted notes. + From this screen, the user can undelete a note or permanently delete it. + ``` + + Нужно предоставить opencode достаточно подробностей, чтобы ему понять, чего вы хотите. Это помогает + поговорить с ним так, как будто вы разговариваете с младшим разработчиком в своей команде. + + :::tip + Дайте opencode много контекста и примеров, чтобы помочь ему понять, что вы + хотеть. + ::: + +2. **Итерация плана** + + Как только он предоставит вам план, вы можете оставить ему отзыв или добавить более подробную информацию. + + ```txt frame="none" + We'd like to design this new screen using a design I've used before. + [Image #1] Take a look at this image and use it as a reference. + ``` + + :::tip + Перетащите изображения в терминал, чтобы добавить их в подсказку. + ::: + + opencode может сканировать любые изображения, которые вы ему предоставляете, и добавлять их в командную строку. Вы можете + сделать это, перетащив изображение в терминал. + +3. **Создайте функцию** + + Как только вы почувствуете себя комфортно с планом, вернитесь в _режим сборки_, + снова нажав клавишу **Tab**. + + ```bash frame="none" + + ``` + + И попросите его внести изменения. + + ```bash frame="none" + Sounds good! Go ahead and make the changes. + ``` + +--- + +### Внесение изменений + +Для более простых изменений вы можете попросить opencode создать их напрямую. +без необходимости предварительного рассмотрения плана. + +```txt frame="none" "@packages/functions/src/settings.ts" "@packages/functions/src/notes.ts" +We need to add authentication to the /settings route. Take a look at how this is +handled in the /notes route in @packages/functions/src/notes.ts and implement +the same logic in @packages/functions/src/settings.ts +``` + +Убедитесь, что предоставили достаточно деталей, чтобы opencode внес корректные изменения. + +--- + +### Отмена изменений + +Допустим, вы просите opencode внести некоторые изменения. + +```txt frame="none" "@packages/functions/src/api/index.ts" +Can you refactor the function in @packages/functions/src/api/index.ts? +``` + +Но вы понимаете, что это не то, чего вы хотели. Вы **можете отменить** изменения +с помощью команды `/undo`. + +```bash frame="none" +/undo +``` + +opencode отменит внесенные вами изменения и покажет исходное сообщение. +снова. + +```txt frame="none" "@packages/functions/src/api/index.ts" +Can you refactor the function in @packages/functions/src/api/index.ts? +``` + +Отсюда вы можете настроить подсказку и попросить opencode повторить попытку. + +:::tip +Вы можете запустить `/undo` несколько раз, чтобы отменить несколько изменений. +::: + +Или вы **можете повторить** изменения с помощью команды `/redo`. + +```bash frame="none" +/redo +``` + +--- + +## Общий доступ + +Разговорами, которые вы ведете с opencode, можно [поделиться с вашей +командой](/docs/share). + +```bash frame="none" +/share +``` + +Это создаст ссылку на текущий разговор и скопирует ее в буфер обмена. + +:::note +По умолчанию общий доступ к беседам не предоставляется. +::: + +Вот [пример диалога](https://opencode.ai/s/4XP1fce5) с opencode. + +--- + +## Настроить + +И все! Теперь вы профессионал в использовании opencode. + +Чтобы создать свою собственную, мы рекомендуем [выбрать тему](/docs/themes), [настроить привязки клавиш](/docs/keybinds), [настроить средства форматирования кода](/docs/formatters), [создать собственные команды](/docs/commands) или поиграться с файлом [opencode config](/docs/config). diff --git a/packages/web/src/content/docs/ru/keybinds.mdx b/packages/web/src/content/docs/ru/keybinds.mdx new file mode 100644 index 0000000000000000000000000000000000000000..bde4e155307bb5f866dcc0ca2d34244bb0bc351d --- /dev/null +++ b/packages/web/src/content/docs/ru/keybinds.mdx @@ -0,0 +1,194 @@ +--- +title: Сочетания клавиш +description: Настройте свои сочетания клавиш. +--- + +opencode имеет список сочетаний клавиш, которые вы можете настроить через `tui.json`. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keybinds": { + "leader": "ctrl+x", + "app_exit": "ctrl+c,ctrl+d,q", + "editor_open": "e", + "theme_list": "t", + "sidebar_toggle": "b", + "scrollbar_toggle": "none", + "username_toggle": "none", + "status_view": "s", + "tool_details": "none", + "session_export": "x", + "session_new": "n", + "session_list": "l", + "session_timeline": "g", + "session_fork": "none", + "session_rename": "none", + "session_share": "none", + "session_unshare": "none", + "session_interrupt": "escape", + "session_compact": "c", + "session_child_first": "down", + "session_child_cycle": "right", + "session_child_cycle_reverse": "left", + "session_parent": "up", + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", + "messages_half_page_up": "ctrl+alt+u", + "messages_half_page_down": "ctrl+alt+d", + "messages_first": "ctrl+g,home", + "messages_last": "ctrl+alt+g,end", + "messages_next": "none", + "messages_previous": "none", + "messages_copy": "y", + "messages_undo": "u", + "messages_redo": "r", + "messages_last_user": "none", + "messages_toggle_conceal": "h", + "model_list": "m", + "model_cycle_recent": "f2", + "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", + "variant_cycle": "ctrl+t", + "variant_list": "none", + "command_list": "ctrl+p", + "agent_list": "a", + "agent_cycle": "tab", + "agent_cycle_reverse": "shift+tab", + "input_clear": "ctrl+c", + "input_paste": "ctrl+v", + "input_submit": "return", + "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input_move_left": "left,ctrl+b", + "input_move_right": "right,ctrl+f", + "input_move_up": "up", + "input_move_down": "down", + "input_select_left": "shift+left", + "input_select_right": "shift+right", + "input_select_up": "shift+up", + "input_select_down": "shift+down", + "input_line_home": "ctrl+a", + "input_line_end": "ctrl+e", + "input_select_line_home": "ctrl+shift+a", + "input_select_line_end": "ctrl+shift+e", + "input_visual_line_home": "alt+a", + "input_visual_line_end": "alt+e", + "input_select_visual_line_home": "alt+shift+a", + "input_select_visual_line_end": "alt+shift+e", + "input_buffer_home": "home", + "input_buffer_end": "end", + "input_select_buffer_home": "shift+home", + "input_select_buffer_end": "shift+end", + "input_delete_line": "ctrl+shift+d", + "input_delete_to_line_end": "ctrl+k", + "input_delete_to_line_start": "ctrl+u", + "input_backspace": "backspace,shift+backspace", + "input_delete": "ctrl+d,delete,shift+delete", + "input_undo": "ctrl+-,super+z", + "input_redo": "ctrl+.,super+shift+z", + "input_word_forward": "alt+f,alt+right,ctrl+right", + "input_word_backward": "alt+b,alt+left,ctrl+left", + "input_select_word_forward": "alt+shift+f,alt+shift+right", + "input_select_word_backward": "alt+shift+b,alt+shift+left", + "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", + "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", + "history_previous": "up", + "history_next": "down", + "terminal_suspend": "ctrl+z", + "terminal_title_toggle": "none", + "tips_toggle": "h", + "display_thinking": "none" + } +} +``` + +--- + +## Клавиша Leader + +opencode использует клавишу `leader` для большинства сочетаний клавиш. Это позволяет избежать конфликтов в вашем терминале. + +По умолчанию `ctrl+x` является клавишей leader, и для большинства действий требуется сначала нажать клавишу leader, а затем сочетание клавиш. Например, чтобы начать новый сеанс, сначала нажмите `ctrl+x`, а затем нажмите `n`. + +Вам не обязательно использовать клавишу leader для привязок клавиш, но мы рекомендуем это сделать. + +--- + +## Отключение привязки клавиш + +Вы можете отключить привязку клавиш, добавив ключ в `tui.json` со значением «none». + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keybinds": { + "session_compact": "none" + } +} +``` + +--- + +## Шорткаты в Desktop-приложении + +Ввод приглашения настольного приложения opencode поддерживает распространенные сочетания клавиш в стиле Readline/Emacs для редактирования текста. Они встроены и в настоящее время не настраиваются через `opencode.json`. + +| Ярлык | Действие | +| -------- | ---------------------------------------------------- | +| `ctrl+a` | Перейти к началу текущей строки | +| `ctrl+e` | Перейти к концу текущей строки | +| `ctrl+b` | Переместить курсор на один символ назад | +| `ctrl+f` | Переместить курсор на один символ вперед | +| `alt+b` | Переместить курсор на одно слово назад | +| `alt+f` | Переместить курсор вперед на одно слово | +| `ctrl+d` | Удалить символ под курсором | +| `ctrl+k` | Удалить до конца строки | +| `ctrl+u` | Удалить до начала строки | +| `ctrl+w` | Удалить предыдущее слово | +| `alt+d` | Удалить следующее слово | +| `ctrl+t` | Поменять местами символы | +| `ctrl+g` | Отменить всплывающие окна/прервать выполнение ответа | + +--- + +## Shift+Enter + +Некоторые терминалы по умолчанию не отправляют клавиши-модификаторы с Enter. Возможно, вам придется настроить терминал на отправку `Shift+Enter` в качестве escape-последовательности. + +### Windows Terminal + +Откройте свой `settings.json` по адресу: + +``` +%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json +``` + +Добавьте это в массив `actions` корневого уровня: + +```json +"actions": [ + { + "command": { + "action": "sendInput", + "input": "\u001b[13;2u" + }, + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +Добавьте это в массив `keybindings` корневого уровня: + +```json +"keybindings": [ + { + "keys": "shift+enter", + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +Сохраните файл и перезапустите Windows Terminal или откройте новую вкладку. diff --git a/packages/web/src/content/docs/ru/mcp-servers.mdx b/packages/web/src/content/docs/ru/mcp-servers.mdx new file mode 100644 index 0000000000000000000000000000000000000000..da88f413f9dc4a7225646356fb62b041ed532b4d --- /dev/null +++ b/packages/web/src/content/docs/ru/mcp-servers.mdx @@ -0,0 +1,511 @@ +--- +title: MCP-серверы +description: Добавьте локальные и удаленные инструменты MCP. +--- + +Вы можете добавить внешние инструменты в opencode, используя _Model Context Protocol_ или MCP. opencode поддерживает как локальные, так и удаленные серверы. + +После добавления инструменты MCP автоматически становятся доступными для LLM наряду со встроенными инструментами. + +--- + +#### Предостережения + +Когда вы используете сервер MCP, он добавляет контекст. Это может быстро сложиться, если у вас много инструментов. Поэтому мы рекомендуем быть осторожными с тем, какие серверы MCP вы используете. + +:::tip +Серверы MCP добавляются к вашему контексту, поэтому будьте осторожны с тем, какие из них вы включаете. +::: + +Некоторые серверы MCP, такие как сервер MCP GitHub, имеют тенденцию добавлять много токенов и могут легко превысить ограничение контекста. + +--- + +## Включение + +Вы можете определить серверы MCP в своем [opencode Config](https://opencode.ai/docs/config/) в разделе `mcp`. Добавьте каждому MCP уникальное имя. Вы можете обратиться к этому MCP по имени при запросе LLM. + +```jsonc title="opencode.jsonc" {6} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "name-of-mcp-server": { + // ... + "enabled": true, + }, + "name-of-other-mcp-server": { + // ... + }, + }, +} +``` + +Вы также можете отключить сервер, установив для `enabled` значение `false`. Это полезно, если вы хотите временно отключить сервер, не удаляя его из конфигурации. + +--- + +### Переопределение удаленных настроек по умолчанию + +Организации могут предоставлять серверы MCP по умолчанию через свою конечную точку `.well-known/opencode`. Эти серверы могут быть отключены по умолчанию, что позволяет пользователям выбирать те, которые им нужны. + +Чтобы включить определенный сервер из удаленной конфигурации вашей организации, добавьте его в локальную конфигурацию с помощью `enabled: true`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } + } +} +``` + +Значения вашей локальной конфигурации переопределяют удаленные значения по умолчанию. Дополнительную информацию см. в [приоритете конфигурации](/docs/config#precedence-order). + +--- + +## Локальные + +Добавьте локальные серверы MCP с помощью `type` в `"local"` внутри объекта MCP. + +```jsonc title="opencode.jsonc" {15} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-local-mcp-server": { + "type": "local", + // Or ["bun", "x", "my-mcp-command"] + "command": ["npx", "-y", "my-mcp-command"], + "enabled": true, + "environment": { + "MY_ENV_VAR": "my_env_var_value", + }, + }, + }, +} +``` + +Эта команда запускает локальный сервер MCP. Вы также можете передать список переменных среды. + +Например, вот как можно добавить тестовый сервер [`@modelcontextprotocol/server-everything`](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) MCP. + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "mcp_everything": { + "type": "local", + "command": ["npx", "-y", "@modelcontextprotocol/server-everything"], + }, + }, +} +``` + +И чтобы использовать его, добавьте `use the mcp_everything tool` в свои подсказки. + +```txt "mcp_everything" +use the mcp_everything tool to add the number 3 and 4 +``` + +--- + +#### Параметры + +Вот все варианты настройки локального сервера MCP. + +| Вариант | Тип | Обязательный | Описание | +| ------------- | ------------------- | ------------ | ------------------------------------------------------------------------------------- | +| `type` | Строка | Да | Тип подключения к серверу MCP должен быть `"local"`. | +| `command` | Массив | Да | Команда и аргументы для запуска сервера MCP. | +| `environment` | Объект | | Переменные среды, которые необходимо установить при запуске сервера. | +| `enabled` | логическое значение | | Включите или отключите сервер MCP при запуске. | +| `timeout` | Число | | Тайм-аут в мс для получения инструментов с сервера MCP. По умолчанию 5000 (5 секунд). | + +--- + +## Удаленные + +Добавьте удаленные серверы MCP, установив для `type` значение `"remote"`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-remote-mcp": { + "type": "remote", + "url": "https://my-mcp-server.com", + "enabled": true, + "headers": { + "Authorization": "Bearer MY_API_KEY" + } + } + } +} +``` + +`url` — это URL-адрес удаленного сервера MCP, а с помощью параметра `headers` вы можете передать список заголовков. + +--- + +#### Параметры + +| Вариант | Тип | Обязательный | Описание | +| --------- | ------------------- | ------------ | ------------------------------------------------------------------------------------- | +| `type` | Строка | Да | Тип подключения к серверу MCP должен быть `"remote"`. | +| `url` | Строка | Да | URL-адрес удаленного сервера MCP. | +| `enabled` | логическое значение | | Включите или отключите сервер MCP при запуске. | +| `headers` | Объект | | Заголовки для отправки с запросом. | +| `oauth` | Объект | | Конфигурация аутентификации OAuth. См. раздел [OAuth](#oauth) ниже. | +| `timeout` | Число | | Тайм-аут в мс для получения инструментов с сервера MCP. По умолчанию 5000 (5 секунд). | + +--- + +## OAuth + +opencode автоматически обрабатывает аутентификацию OAuth для удаленных серверов MCP. Когда серверу требуется аутентификация, opencode: + +1. Обнаружьте ответ 401 и инициируйте поток OAuth. +2. Используйте **Динамическую регистрацию клиента (RFC 7591)**, если это поддерживается сервером. +3. Надежно храните токены для будущих запросов + +--- + +### Автоматически + +Для большинства серверов MCP с поддержкой OAuth не требуется никакой специальной настройки. Просто настройте удаленный сервер: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp" + } + } +} +``` + +Если сервер требует аутентификации, opencode предложит вам пройти аутентификацию при первой попытке его использования. Если нет, вы можете [вручную запустить поток ](#authenticating) с помощью `opencode mcp auth `. + +--- + +### Предварительная регистрация + +Если у вас есть учетные данные клиента от поставщика сервера MCP, вы можете их настроить: + +```json title="opencode.json" {7-11} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "{env:MY_MCP_CLIENT_ID}", + "clientSecret": "{env:MY_MCP_CLIENT_SECRET}", + "scope": "tools:read tools:execute" + } + } + } +} +``` + +--- + +### Аутентификация + +Вы можете вручную активировать аутентификацию или управлять учетными данными. + +Аутентификация с помощью определенного сервера MCP: + +```bash +opencode mcp auth my-oauth-server +``` + +Перечислите все серверы MCP и их статус аутентификации: + +```bash +opencode mcp list +``` + +Удалить сохраненные учетные данные: + +```bash +opencode mcp logout my-oauth-server +``` + +Команда `mcp auth` откроет ваш браузер для авторизации. После того как вы авторизуетесь, opencode надежно сохранит токены в `~/.local/share/opencode/mcp-auth.json`. + +--- + +#### Отключение OAuth + +Если вы хотите отключить автоматический OAuth для сервера (например, для серверов, которые вместо этого используют ключи API), установите для `oauth` значение `false`: + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-api-key-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": false, + "headers": { + "Authorization": "Bearer {env:MY_API_KEY}" + } + } + } +} +``` + +--- + +#### Параметры OAuth + +| Вариант | Тип | Описание | +| -------------- | --------------- | ---------------------------------------------------------------------------------- | +| `oauth` | Object \| false | Объект конфигурации OAuth или `false`, чтобы отключить автообнаружение OAuth. | +| `clientId` | String | OAuth client ID. Если не указан, будет выполнена динамическая регистрация клиента. | +| `clientSecret` | String | OAuth client secret, если этого требует сервер авторизации. | +| `scope` | String | OAuth scopes для запроса во время авторизации. | + +#### Отладка + +Если удаленный сервер MCP не может аутентифицироваться, вы можете диагностировать проблемы с помощью: + +```bash +# View auth status for all OAuth-capable servers +opencode mcp auth list + +# Debug connection and OAuth flow for a specific server +opencode mcp debug my-oauth-server +``` + +Команда `mcp debug` показывает текущий статус аутентификации, проверяет соединение HTTP и пытается выполнить поток обнаружения OAuth. + +--- + +## Управление + +Ваши MCP доступны в виде инструментов opencode наряду со встроенными инструментами. Таким образом, вы можете управлять ими через конфигурацию opencode, как и любым другим инструментом. + +--- + +### Глобально + +Это означает, что вы можете включать или отключать их глобально. + +```json title="opencode.json" {14} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-foo"] + }, + "my-mcp-bar": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-bar"] + } + }, + "tools": { + "my-mcp-foo": false + } +} +``` + +Мы также можем использовать шаблон glob, чтобы отключить все соответствующие MCP. + +```json title="opencode.json" {14} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-foo"] + }, + "my-mcp-bar": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-bar"] + } + }, + "tools": { + "my-mcp*": false + } +} +``` + +Здесь мы используем шаблон `my-mcp*` для отключения всех MCP. + +--- + +### Для каждого агента + +Если у вас большое количество серверов MCP, вы можете включить их только для каждого агента и отключить глобально. Для этого: + +1. Отключите его как инструмент глобально. +2. В вашей [конфигурации агента](/docs/agents#tools) включите сервер MCP в качестве инструмента. + +```json title="opencode.json" {11, 14-18} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp": { + "type": "local", + "command": ["bun", "x", "my-mcp-command"], + "enabled": true + } + }, + "tools": { + "my-mcp*": false + }, + "agent": { + "my-agent": { + "tools": { + "my-mcp*": true + } + } + } +} +``` + +--- + +#### Glob-шаблоны + +Шаблон glob использует простые шаблоны подстановки регулярных выражений: + +- `*` соответствует нулю или более любого символа (например, `"my-mcp*"` соответствует `my-mcp_search`, `my-mcp_list` и т. д.). +- `?` соответствует ровно одному символу. +- Все остальные символы совпадают буквально + +:::note +Инструменты сервера MCP регистрируются с именем сервера в качестве префикса, поэтому, чтобы отключить все инструменты для сервера, просто используйте: + +``` +"mymcpservername_*": false +``` + +::: + +--- + +## Примеры + +Ниже приведены примеры некоторых распространенных серверов MCP. Вы можете отправить PR, если хотите документировать другие серверы. + +--- + +### Sentry + +Добавьте [сервер Sentry MCP](https://mcp.sentry.dev) для взаимодействия с вашими проектами и проблемами Sentry. + +```json title="opencode.json" {4-8} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "sentry": { + "type": "remote", + "url": "https://mcp.sentry.dev/mcp", + "oauth": {} + } + } +} +``` + +После добавления конфигурации пройдите аутентификацию с помощью Sentry: + +```bash +opencode mcp auth sentry +``` + +Откроется окно браузера для завершения процесса OAuth и подключения opencode к вашей учетной записи Sentry. + +После аутентификации вы можете использовать инструменты Sentry в своих подсказках для запроса данных о проблемах, проектах и ​​ошибках. + +```txt "use sentry" +Show me the latest unresolved issues in my project. use sentry +``` + +--- + +### Context7 + +Добавьте [сервер Context7 MCP](https://github.com/upstash/context7) для поиска в документах. + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp" + } + } +} +``` + +Если вы зарегистрировали бесплатную учетную запись, вы можете использовать свой ключ API и получить более высокие ограничения скорости. + +```json title="opencode.json" {7-9} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + } + } + } +} +``` + +Здесь мы предполагаем, что у вас установлена ​​переменная среды `CONTEXT7_API_KEY`. + +Добавьте `use context7` в запросы на использование сервера Context7 MCP. + +```txt "use context7" +Configure a Cloudflare Worker script to cache JSON API responses for five minutes. use context7 +``` + +Альтернативно вы можете добавить что-то подобное в свой файл [AGENTS.md](/docs/rules/). + +```md title="AGENTS.md" +When you need to search docs, use `context7` tools. +``` + +--- + +### Grep by Vercel + +Добавьте сервер MCP [Grep от Vercel](https://grep.app) для поиска по фрагментам кода на GitHub. + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "gh_grep": { + "type": "remote", + "url": "https://mcp.grep.app" + } + } +} +``` + +Поскольку мы назвали наш сервер MCP `gh_grep`, вы можете добавить `use the gh_grep tool` в свои запросы, чтобы агент мог его использовать. + +```txt "use the gh_grep tool" +What's the right way to set a custom domain in an SST Astro component? use the gh_grep tool +``` + +Альтернативно вы можете добавить что-то подобное в свой файл [AGENTS.md](/docs/rules/). + +```md title="AGENTS.md" +If you are unsure how to do something, use `gh_grep` to search code examples from GitHub. +``` diff --git a/packages/web/src/content/docs/ru/models.mdx b/packages/web/src/content/docs/ru/models.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a23a02f6a57bb18e4a02e2c5c78d2c7576763f72 --- /dev/null +++ b/packages/web/src/content/docs/ru/models.mdx @@ -0,0 +1,223 @@ +--- +title: Модели +description: Настройка поставщика и модели LLM. +--- + +opencode использует [AI SDK](https://ai-sdk.dev/) и [Models.dev](https://models.dev) для поддержки **более 75 поставщиков LLM** и поддерживает запуск локальных моделей. + +--- + +## Провайдеры + +Большинство популярных провайдеров предварительно загружены по умолчанию. Если вы добавили учетные данные для поставщика с помощью команды `/connect`, они будут доступны при запуске opencode. + +Узнайте больше о [providers](/docs/providers). + +--- + +## Выберите модель + +После того, как вы настроили своего провайдера, вы можете выбрать нужную модель, введя: + +```bash frame="none" +/models +``` + +--- + +## Рекомендуемые модели + +Моделей очень много, новые выходят каждую неделю. + +:::tip +Рассмотрите возможность использования одной из моделей, которые мы рекомендуем. +::: + +Однако лишь немногие из них хороши как в генерации кода, так и в вызове инструментов. + +Вот несколько моделей, которые хорошо работают с opencode (в произвольном порядке). (Это не исчерпывающий список и не обязательно актуальный): + +- GPT 5.2 +- Кодекс GPT 5.1 +- Claude Opus 4.5 +- Claude Sonnet 4.5 +- MiniMax M2.1 +- Gemini 3 Pro + +--- + +## Установить значение по умолчанию + +Чтобы установить одну из них в качестве модели по умолчанию, вы можете установить ключ `model` в вашем +Конфигурация opencode. + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "model": "lmstudio/google/gemma-3n-e4b" +} +``` + +Здесь полный идентификатор `provider_id/model_id`. Например, если вы используете [OpenCode Zen](/docs/zen), вы должны использовать `opencode/gpt-5.1-codex` для кодекса GPT 5.1. + +Если вы настроили [пользовательский поставщик](/docs/providers#custom), `provider_id` — это ключ из части `provider` вашей конфигурации, а `model_id` — это ключ из `provider.models`. + +--- + +## Настройка моделей + +Вы можете глобально настроить параметры модели через файл config. + +```jsonc title="opencode.jsonc" {7-12,19-24} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openai": { + "models": { + "gpt-5": { + "options": { + "reasoningEffort": "high", + "textVerbosity": "low", + "reasoningSummary": "auto", + "include": ["reasoning.encrypted_content"], + }, + }, + }, + }, + "anthropic": { + "models": { + "claude-sonnet-4-5-20250929": { + "options": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000, + }, + }, + }, + }, + }, + }, +} +``` + +Здесь мы настраиваем глобальные параметры для двух встроенных моделей: `gpt-5` при доступе через поставщика `openai` и `claude-sonnet-4-20250514` при доступе через поставщика `anthropic`. +Названия встроенных поставщиков и моделей можно найти на сайте [Models.dev](https://models.dev). + +Вы также можете настроить эти параметры для любых используемых вами агентов. Конфигурация агента переопределяет любые глобальные параметры здесь. [Подробнее](/docs/agents/#additional). + +Вы также можете определить собственные варианты, расширяющие встроенные. Варианты позволяют настраивать разные параметры для одной и той же модели без создания повторяющихся записей: + +```jsonc title="opencode.jsonc" {6-21} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "opencode": { + "models": { + "gpt-5": { + "variants": { + "high": { + "reasoningEffort": "high", + "textVerbosity": "low", + "reasoningSummary": "auto", + }, + "low": { + "reasoningEffort": "low", + "textVerbosity": "low", + "reasoningSummary": "auto", + }, + }, + }, + }, + }, + }, +} +``` + +--- + +## Варианты + +Многие модели поддерживают несколько вариантов с разными конфигурациями. opencode поставляется со встроенными вариантами по умолчанию для популярных провайдеров. + +### Встроенные варианты + +opencode поставляется с вариантами по умолчанию для многих провайдеров: + +**Anthropic**: + +- `high` — Бюджет рассуждений: высокий (по умолчанию) +- `max` — Максимальный бюджет рассуждений + +**OpenAI**: + +Зависит от модели, но примерно: + +- `none` — Без рассуждений. +- `minimal` — Минимальные усилия для рассуждений +- `low` — Низкие усилия для рассуждений. +- `medium` — Средние усилия для рассуждений. +- `high` — Высокие усилия для рассуждений. +- `xhigh` — Сверхвысокие усилия для рассуждений. + +**Google**: + +- `low` – меньший бюджет усилий/токенов. +- `high` — более высокий бюджет усилий/токенов + +:::tip +Этот список не является исчерпывающим. Многие другие провайдеры также имеют встроенные настройки по умолчанию. +::: + +### Пользовательские варианты + +Вы можете переопределить существующие варианты или добавить свои собственные: + +```jsonc title="opencode.jsonc" {7-18} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openai": { + "models": { + "gpt-5": { + "variants": { + "thinking": { + "reasoningEffort": "high", + "textVerbosity": "low", + }, + "fast": { + "disabled": true, + }, + }, + }, + }, + }, + }, +} +``` + +### Переключение вариантов + +Используйте сочетание клавиш `variant_cycle` для быстрого переключения между вариантами. [Подробнее ](/docs/keybinds). + +--- + +## Загрузка моделей + +Когда opencode запускается, он проверяет модели в следующем порядке приоритета: + +1. CLI-флаг `--model` или `-m`. Формат тот же, что и в файле конфигурации: `provider_id/model_id`. + +2. Список моделей в конфигурации opencode. + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-20250514" + } + ``` + + Здесь используется формат `provider/model`. + +3. Последняя использованная модель. + +4. Первая модель, использующая внутренний приоритет. diff --git a/packages/web/src/content/docs/ru/network.mdx b/packages/web/src/content/docs/ru/network.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b80c847899cf6e210fbdd411ea0267fafc3cde0d --- /dev/null +++ b/packages/web/src/content/docs/ru/network.mdx @@ -0,0 +1,57 @@ +--- +title: Сеть +description: Настройте прокси и пользовательские сертификаты. +--- + +opencode поддерживает стандартные переменные среды прокси-сервера и пользовательские сертификаты для сетевых сред предприятия. + +--- + +## Прокси + +opencode учитывает стандартные переменные среды прокси. + +```bash +# HTTPS proxy (recommended) +export HTTPS_PROXY=https://proxy.example.com:8080 + +# HTTP proxy (if HTTPS not available) +export HTTP_PROXY=http://proxy.example.com:8080 + +# Bypass proxy for local server (required) +export NO_PROXY=localhost,127.0.0.1 +``` + +:::caution +TUI взаимодействует с локальным HTTP-сервером. Вы должны обойти прокси-сервер для этого соединения, чтобы избежать петель маршрутизации. +::: + +Вы можете настроить порт и имя хоста сервера, используя [CLI flags](/docs/cli#run). + +--- + +### Аутентификация + +Если ваш прокси-сервер требует базовой аутентификации, включите учетные данные в URL-адрес. + +```bash +export HTTPS_PROXY=http://username:password@proxy.example.com:8080 +``` + +:::caution +Избегайте жесткого кодирования паролей. Используйте переменные среды или безопасное хранилище учетных данных. +::: + +Для прокси-серверов, требующих расширенной аутентификации, например NTLM или Kerberos, рассмотрите возможность использования шлюза LLM, поддерживающего ваш метод аутентификации. + +--- + +## Пользовательские сертификаты + +Если ваше предприятие использует собственные центры сертификации для HTTPS-соединений, настройте opencode, чтобы доверять им. + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/ca-cert.pem +``` + +Это работает как для прокси-соединений, так и для прямого доступа к API. diff --git a/packages/web/src/content/docs/ru/permissions.mdx b/packages/web/src/content/docs/ru/permissions.mdx new file mode 100644 index 0000000000000000000000000000000000000000..05028c8bb3d62e14482eb244ce5ffac35e9fdb99 --- /dev/null +++ b/packages/web/src/content/docs/ru/permissions.mdx @@ -0,0 +1,235 @@ +--- +title: Разрешения +description: Контролируйте, какие действия требуют одобрения для выполнения. +--- + +opencode использует конфигурацию `permission`, чтобы решить, должно ли данное действие выполняться автоматически, запрашивать вас или блокироваться. + +Начиная с `v1.1.1`, устаревшая логическая конфигурация `tools` устарела и была объединена с `permission`. Старая конфигурация `tools` по-прежнему поддерживается для обеспечения обратной совместимости. + +--- + +## Действия + +Каждое правило разрешения разрешается в одно из: + +- `"allow"` — запуск без одобрения +- `"ask"` — запрос на одобрение +- `"deny"` — заблокировать действие + +--- + +## Конфигурация + +Вы можете устанавливать разрешения глобально (с помощью `*`) и переопределять определенные инструменты. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "*": "ask", + "bash": "allow", + "edit": "deny" + } +} +``` + +Вы также можете установить все разрешения одновременно: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": "allow" +} +``` + +--- + +## Детальные правила (синтаксис объекта) + +Для большинства разрешений вы можете использовать объект для применения различных действий на основе входных данных инструмента. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "npm *": "allow", + "rm *": "deny", + "grep *": "allow" + }, + "edit": { + "*": "deny", + "packages/web/src/content/docs/*.mdx": "allow" + } + } +} +``` + +Правила оцениваются по шаблону, при этом **выигрывает последнее совпадающее правило**. Обычно сначала ставится универсальное правило `"*"`, а после него — более конкретные правила. + +### Подстановочные знаки + +В шаблонах разрешений используется простое сопоставление с подстановочными знаками: + +- `*` соответствует нулю или более любого символа. +- `?` соответствует ровно одному символу +- Все остальные символы совпадают буквально + +### Расширение домашнего каталога + +Вы можете использовать `~` или `$HOME` в начале шаблона для ссылки на ваш домашний каталог. Это особенно полезно для правил [`external_directory`](#external-directories). + +- `~/projects/*` -> `/Users/username/projects/*` +- `$HOME/projects/*` -> `/Users/username/projects/*` +- `~` -> `/Users/username` + +### Внешние каталоги + +Используйте `external_directory`, чтобы разрешить вызовы инструментов, затрагивающие пути за пределами рабочего каталога, в котором был запущен opencode. Это применимо к любому инструменту, который принимает путь в качестве входных данных (например, `read`, `edit`, `glob`, `grep` и многие команды `bash`). + +Расширение дома (например, `~/...`) влияет только на запись шаблона. Он не делает внешний путь частью текущего рабочего пространства, поэтому пути за пределами рабочего каталога все равно должны быть разрешены через `external_directory`. + +Например, это позволяет получить доступ ко всему, что находится под `~/projects/personal/`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "~/projects/personal/**": "allow" + } + } +} +``` + +Любой каталог, разрешенный здесь, наследует те же настройки по умолчанию, что и текущая рабочая область. Поскольку для [`read` по умолчанию установлено значение `allow`](#defaults), чтение также разрешено для записей под `external_directory`, если оно не переопределено. Добавьте явные правила, когда инструмент должен быть ограничен в этих путях, например, блокировать редактирование при сохранении чтения: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "~/projects/personal/**": "allow" + }, + "edit": { + "~/projects/personal/**": "deny" + } + } +} +``` + +Держите список сосредоточенным на доверенных путях и добавляйте дополнительные правила разрешения или запрета по мере необходимости для других инструментов (например, `bash`). + +--- + +## Доступные разрешения + +Разрешения opencode привязаны к имени инструмента, а также к нескольким мерам безопасности: + +- `read` — чтение файла (соответствует пути к файлу) +- `edit` — все модификации файлов (охватывает `edit`, `write`, `patch`) +- `glob` — подстановка файла (соответствует шаблону подстановки) +- `grep` — поиск по контенту (соответствует шаблону регулярного выражения) +- `bash` — запуск shell-команд (соответствует проанализированным командам, например `git status --porcelain`) +- `task` — запуск субагентов (соответствует типу субагента) +- `skill` — загрузка навыка (соответствует названию навыка) +- `lsp` — выполнение запросов LSP (в настоящее время не детализированных) +- `webfetch` — получение URL-адреса (соответствует URL-адресу) +- `websearch` — поиск в сети (соответствует запросу) +- `external_directory` — срабатывает, когда инструмент касается путей за пределами рабочего каталога проекта. +- `doom_loop` — срабатывает, когда один и тот же вызов инструмента повторяется 3 раза с одинаковым вводом. + +--- + +## По умолчанию + +Если вы ничего не укажете, opencode запустится с разрешенных значений по умолчанию: + +- Большинство разрешений по умолчанию имеют значение `"allow"`. +- `doom_loop` и `external_directory` по умолчанию равны `"ask"`. +- `read` — это `"allow"`, но файлы `.env` по умолчанию запрещены: + +```json title="opencode.json" +{ + "permission": { + "read": { + "*": "allow", + "*.env": "deny", + "*.env.*": "deny", + "*.env.example": "allow" + } + } +} +``` + +--- + +## Что означает «ask» + +Когда opencode запрашивает одобрение, пользовательский интерфейс предлагает три результата: + +- `once` — утвердить только этот запрос +- `always` — одобрять будущие запросы, соответствующие предложенным шаблонам (до конца текущего сеанса opencode). +- `reject` — отклонить запрос + +Набор шаблонов, которые одобрит `always`, предоставляется инструментом (например, разрешения bash обычно включают в белый список безопасный префикс команды, такой как `git status*`). + +--- + +## Агенты + +Вы можете переопределить разрешения для каждого агента. Разрешения агента объединяются с глобальной конфигурацией, и правила агента имеют приоритет. [Подробнее](/docs/agents#permissions) о разрешениях агента. + +:::note +Более подробные примеры сопоставления с образцом см. в разделе [Детальные правила (синтаксис объекта)](#granular-rules-object-syntax) выше. +::: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "git commit *": "deny", + "git push *": "deny", + "grep *": "allow" + } + }, + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "git commit *": "ask", + "git push *": "deny", + "grep *": "allow" + } + } + } + } +} +``` + +Вы также можете настроить разрешения агента в Markdown: + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Code review without edits +mode: subagent +permission: + edit: deny + bash: ask + webfetch: deny +--- + +Only analyze code and suggest changes. +``` + +:::tip +Используйте сопоставление с образцом для команд с аргументами. `"grep *"` разрешает `grep pattern file.txt`, а сам `"grep"` блокирует его. Такие команды, как `git status`, работают по умолчанию, но требуют явного разрешения (например, `"git status *"`) при передаче аргументов. +::: diff --git a/packages/web/src/content/docs/ru/providers.mdx b/packages/web/src/content/docs/ru/providers.mdx new file mode 100644 index 0000000000000000000000000000000000000000..39aae9e09630736d22b9ff3528217839d1032b96 --- /dev/null +++ b/packages/web/src/content/docs/ru/providers.mdx @@ -0,0 +1,1987 @@ +--- +title: Провайдеры +description: Использование любого провайдера LLM в opencode. +--- + +import config from "../../../../config.mjs" +export const console = config.console + +opencode использует [AI SDK](https://ai-sdk.dev/) и [Models.dev](https://models.dev) для поддержки **более 75 поставщиков LLM** и поддерживает запуск локальных моделей. + +Чтобы добавить провайдера, вам необходимо: + +1. Добавьте ключи API для провайдера с помощью команды `/connect`. +2. Настройте провайдера в вашей конфигурации opencode. + +--- + +### Учетные данные + +Когда вы добавляете ключи API провайдера с помощью команды `/connect`, они сохраняются +в `~/.local/share/opencode/auth.json`. + +--- + +### Настройка + +Вы можете настроить поставщиков через раздел `provider` в вашем opencode. +конфиг. + +--- + +#### Базовый URL + +Вы можете настроить базовый URL-адрес для любого провайдера, установив параметр `baseURL`. Это полезно при использовании прокси-сервисов или пользовательских конечных точек. + +```json title="opencode.json" {6} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "baseURL": "https://api.anthropic.com/v1" + } + } + } +} +``` + +--- + +## OpenCode Zen + +OpenCode Zen — это список моделей, предоставленный командой opencode, которые были +протестировано и проверено на хорошую работу с opencode. [Подробнее](/docs/zen). + +:::tip +Если вы новичок, мы рекомендуем начать с OpenCode Zen. +::: + +1. Запустите команду `/connect` в TUI, выберите `OpenCode Zen` и перейдите по адресу [opencode.ai/auth](https://opencode.ai/zen). + + ```txt + /connect + ``` + +2. Войдите в систему, добавьте свои платежные данные и скопируйте ключ API. + +3. Вставьте свой ключ API. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите `/models` в TUI, чтобы просмотреть список рекомендуемых нами моделей. + + ```txt + /models + ``` + +Он работает как любой другой поставщик в opencode и его использование совершенно необязательно. + +--- + +## OpenCode Go + +OpenCode Go — это недорогой план подписки, обеспечивающий надежный доступ к популярным открытым моделям кодирования, предоставляемым командой opencode, которые были +протестированы и проверены на хорошую работу с opencode. + +1. Запустите команду `/connect` в TUI, выберите `OpenCode Go` и перейдите по адресу [opencode.ai/auth](https://opencode.ai/zen). + + ```txt + /connect + ``` + +2. Войдите в систему, добавьте свои платежные данные и скопируйте ключ API. + +3. Вставьте свой ключ API. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите `/models` в TUI, чтобы просмотреть список рекомендуемых нами моделей. + + ```txt + /models + ``` + +Он работает как любой другой поставщик в opencode и его использование совершенно необязательно. + +--- + +## Каталог + +Рассмотрим некоторых провайдеров подробнее. Если вы хотите добавить провайдера в список, смело открывайте PR. + +:::note +Не видите здесь провайдера? Откройте PR. +::: + +--- + +### 302.AI + +1. Перейдите в консоль 302.AI](https://302.ai/), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **302.AI**. + + ```txt + /connect + ``` + +3. Введите свой ключ API 302.AI. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель. + + ```txt + /models + ``` + +--- + +### Amazon Bedrock + +Чтобы использовать Amazon Bedrock с opencode: + +1. Перейдите в **Каталог моделей** в консоли Amazon Bedrock и запросите + доступ к нужным моделям. + + :::tip + Вам необходимо иметь доступ к нужной модели в Amazon Bedrock. + ::: + +2. **Настройте аутентификацию** одним из следующих способов: + + #### Переменные среды (быстрый старт) + + Установите одну из этих переменных среды при запуске opencode: + + ```bash + # Option 1: Using AWS access keys + AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=YYY opencode + + # Option 2: Using named AWS profile + AWS_PROFILE=my-profile opencode + + # Option 3: Using Bedrock bearer token + AWS_BEARER_TOKEN_BEDROCK=XXX opencode + ``` + + Или добавьте их в свой профиль bash: + + ```bash title="~/.bash_profile" + export AWS_PROFILE=my-dev-profile + export AWS_REGION=us-east-1 + ``` + + #### Файл конфигурации (рекомендуется) + + Для конкретной или постоянной конфигурации проекта используйте `opencode.json`: + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "my-aws-profile" + } + } + } + } + ``` + + **Доступные варианты:** + - `region` – регион AWS (например, `us-east-1`, `eu-west-1`). + - `profile` – именованный профиль AWS из `~/.aws/credentials`. + - `endpoint` — URL-адрес пользовательской конечной точки для конечных точек VPC (псевдоним для общей опции `baseURL`). + + :::tip + Параметры файла конфигурации имеют приоритет над переменными среды. + ::: + + #### Дополнительно: конечные точки VPC + + Если вы используете конечные точки VPC для Bedrock: + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "production", + "endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" + } + } + } + } + ``` + + :::note + Параметр `endpoint` — это псевдоним общего параметра `baseURL`, использующий терминологию, специфичную для AWS. Если указаны и `endpoint`, и `baseURL`, `endpoint` имеет приоритет. + ::: + + #### Методы аутентификации + - **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**: создайте пользователя IAM и сгенерируйте ключи доступа в консоли AWS. + - **`AWS_PROFILE`**: использовать именованные профили из `~/.aws/credentials`. Сначала настройте `aws configure --profile my-profile` или `aws sso login`. + - **`AWS_BEARER_TOKEN_BEDROCK`**: создание долгосрочных ключей API из консоли Amazon Bedrock. + - **`AWS_WEB_IDENTITY_TOKEN_FILE`/`AWS_ROLE_ARN`**: для EKS IRSA (роли IAM для учетных записей служб) или других сред Kubernetes с федерацией OIDC. Эти переменные среды автоматически вводятся Kubernetes при использовании аннотаций учетной записи службы. + + #### Приоритет аутентификации + + Amazon Bedrock использует следующий приоритет аутентификации: + 1. **Токен носителя** — переменная среды `AWS_BEARER_TOKEN_BEDROCK` или токен из команды `/connect`. + 2. **Цепочка учетных данных AWS** — профиль, ключи доступа, общие учетные данные, роли IAM, токены веб-идентификации (EKS IRSA), метаданные экземпляра. + + :::note + Когда токен-носитель установлен (через `/connect` или `AWS_BEARER_TOKEN_BEDROCK`), он имеет приоритет над всеми методами учетных данных AWS, включая настроенные профили. + ::: + +3. Запустите команду `/models`, чтобы выбрать нужную модель. + + ```txt + /models + ``` + +:::note +Для пользовательских профилей вывода используйте имя модели и поставщика в ключе и задайте для свойства `id` значение arn. Это обеспечивает правильное кэширование: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + // ... + "models": { + "anthropic-claude-sonnet-4.5": { + "id": "arn:aws:bedrock:us-east-1:xxx:application-inference-profile/yyy" + } + } + } + } +} +``` + +::: + +--- + +### Anthropic + +1. После регистрации введите команду `/connect` и выберите Anthropic. + + ```txt + /connect + ``` + +2. Здесь вы можете выбрать опцию **Claude Pro/Max**, и ваш браузер откроется. + и попросите вас пройти аутентификацию. + + ```txt + ┌ Select auth method + │ + │ Claude Pro/Max + │ Create an API Key + │ Manually enter API Key + └ + ``` + +3. Теперь все модели Anthropic должны быть доступны при использовании команды `/models`. + + ```txt + /models + ``` + +:::info +Использование вашей подписки Claude Pro/Max в opencode официально не поддерживается [Anthropic](https://anthropic.com). +::: + +##### Использование ключей API + +Вы также можете выбрать **Создать ключ API**, если у вас нет подписки Pro/Max. Он также откроет ваш браузер и попросит вас войти в Anthropic и предоставит вам код, который вы можете вставить в свой терминал. + +Или, если у вас уже есть ключ API, вы можете выбрать **Ввести ключ API вручную** и вставить его в свой терминал. + +--- + +### Atomic Chat + +Вы можете настроить opencode для работы с локальными моделями через [Atomic Chat](https://atomic.chat) — десктопное приложение, которое запускает локальные LLM за OpenAI-совместимым API-сервером (конечная точка по умолчанию `http://127.0.0.1:1337/v1`). + +```json title="opencode.json" "atomic-chat" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "atomic-chat": { + "npm": "@ai-sdk/openai-compatible", + "name": "Atomic Chat (local)", + "options": { + "baseURL": "http://127.0.0.1:1337/v1" + }, + "models": { + "": { + "name": "" + } + } + } + } +} +``` + +В этом примере: + +- `atomic-chat` — пользовательский идентификатор провайдера. Это может быть любая строка. +- `npm` указывает пакет, используемый для этого провайдера. Здесь используется `@ai-sdk/openai-compatible` для любых OpenAI-совместимых API. +- `name` — отображаемое имя провайдера в интерфейсе. +- `options.baseURL` — конечная точка локального сервера. Измените хост и порт в соответствии с вашей конфигурацией Atomic Chat. +- `models` — карта идентификаторов моделей и их отображаемых имён. Каждый ID должен совпадать со значением `id`, которое возвращает `GET /v1/models` — выполните `curl http://127.0.0.1:1337/v1/models`, чтобы увидеть ID моделей, загруженных в Atomic Chat. + +:::tip +Если вызовы инструментов работают нестабильно, выберите загруженную модель с хорошей поддержкой tool calling (например, вариант из семейств Qwen-Coder или DeepSeek-Coder). +::: + +--- + +### Azure OpenAI + +:::note +Если вы столкнулись с ошибками «Извините, но я не могу помочь с этим запросом», попробуйте изменить фильтр содержимого с **DefaultV2** на **Default** в своем ресурсе Azure. +::: + +1. Перейдите на [портал Azure](https://portal.azure.com/) и создайте ресурс **Azure OpenAI**. Вам понадобится: + - **Имя ресурса**: оно становится частью вашей конечной точки API (`https://RESOURCE_NAME.openai.azure.com/`). + - **Ключ API**: `KEY 1` или `KEY 2` из вашего ресурса. + +2. Перейдите в [Azure AI Foundry](https://ai.azure.com/) и разверните модель. + + :::примечание + Для правильной работы opencode имя развертывания должно совпадать с именем модели. + ::: + +3. Запустите команду `/connect` и найдите **Azure**. + + ```txt + /connect + ``` + +4. Введите свой ключ API. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +5. Задайте имя ресурса как переменную среды: + + ```bash + AZURE_RESOURCE_NAME=XXX opencode + ``` + + Или добавьте его в свой профиль bash: + + ```bash title="~/.bash_profile" + export AZURE_RESOURCE_NAME=XXX + ``` + +6. Запустите команду `/models`, чтобы выбрать развернутую модель. + + ```txt + /models + ``` + +--- + +### Azure Cognitive Services + +1. Перейдите на [портал Azure](https://portal.azure.com/) и создайте ресурс **Azure OpenAI**. Вам понадобится: + - **Имя ресурса**: оно становится частью вашей конечной точки API (`https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/`). + - **Ключ API**: `KEY 1` или `KEY 2` из вашего ресурса. + +2. Перейдите в [Azure AI Foundry](https://ai.azure.com/) и разверните модель. + + :::примечание + Для правильной работы opencode имя развертывания должно совпадать с именем модели. + ::: + +3. Запустите команду `/connect` и найдите **Azure Cognitive Services**. + + ```txt + /connect + ``` + +4. Введите свой ключ API. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +5. Задайте имя ресурса как переменную среды: + + ```bash + AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX opencode + ``` + + Или добавьте его в свой профиль bash: + + ```bash title="~/.bash_profile" + export AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX + ``` + +6. Запустите команду `/models`, чтобы выбрать развернутую модель. + + ```txt + /models + ``` + +--- + +### Baseten + +1. Перейдите в [Baseten](https://app.baseten.co/), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **Baseten**. + + ```txt + /connect + ``` + +3. Введите свой ключ API Baseten. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель. + + ```txt + /models + ``` + +--- + +### Cerebras + +1. Перейдите в [консоль Cerebras](https://inference.cerebras.ai/), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **Cerebras**. + + ```txt + /connect + ``` + +3. Введите свой ключ API Cerebras. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать такую ​​модель, как _Qwen 3 Coder 480B_. + + ```txt + /models + ``` + +--- + +### Cloudflare AI Gateway + +Cloudflare AI Gateway позволяет вам получать доступ к моделям OpenAI, Anthropic, Workers AI и т. д. через единую конечную точку. Благодаря [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) вам не нужны отдельные ключи API для каждого провайдера. + +1. Перейдите на [панель управления Cloudflare](https://dash.cloudflare.com/), выберите **AI** > **AI Gateway** и создайте новый шлюз. + +2. Установите идентификатор своей учетной записи и идентификатор шлюза в качестве переменных среды. + + ```bash title="~/.bash_profile" + export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id + export CLOUDFLARE_GATEWAY_ID=your-gateway-id + ``` + +3. Запустите команду `/connect` и найдите **Cloudflare AI Gateway**. + + ```txt + /connect + ``` + +4. Введите свой токен API Cloudflare. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + + Или установите его как переменную среды. + + ```bash title="~/.bash_profile" + export CLOUDFLARE_API_TOKEN=your-api-token + ``` + +5. Запустите команду `/models`, чтобы выбрать модель. + + ```txt + /models + ``` + + Вы также можете добавлять модели через конфигурацию opencode. + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "cloudflare-ai-gateway": { + "models": { + "openai/gpt-4o": {}, + "anthropic/claude-sonnet-4": {} + } + } + } + } + ``` + +--- + +### Cortecs + +1. Перейдите в [консоль Cortecs](https://cortecs.ai/), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **Cortecs**. + + ```txt + /connect + ``` + +3. Введите свой ключ API Cortecs. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать такую ​​модель, как _Kimi K2 Instruct_. + + ```txt + /models + ``` + +--- + +### DeepSeek + +1. Перейдите в [консоль DeepSeek](https://platform.deepseek.com/), создайте учетную запись и нажмите **Создать новый ключ API**. + +2. Запустите команду `/connect` и найдите **DeepSeek**. + + ```txt + /connect + ``` + +3. Введите свой ключ API DeepSeek. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель DeepSeek, например _DeepSeek V4 Pro_. + + ```txt + /models + ``` + +--- + +### Deep Infra + +1. Перейдите на панель мониторинга Deep Infra](https://deepinfra.com/dash), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **Deep Infra**. + + ```txt + /connect + ``` + +3. Введите свой ключ API Deep Infra. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель. + + ```txt + /models + ``` + +--- + +### FrogBot + +1. Перейдите на [панель FrogBot](https://app.frogbot.ai/signup), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **FrogBot**. + + ```txt + /connect + ``` + +3. Введите ключ API FrogBot. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель. + + ```txt + /models + ``` + +--- + +### Fireworks AI + +1. Перейдите в [консоль Fireworks AI](https://app.fireworks.ai/), создайте учетную запись и нажмите **Создать ключ API**. + +2. Запустите команду `/connect` и найдите **Fireworks AI**. + + ```txt + /connect + ``` + +3. Введите ключ API Fireworks AI. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать такую ​​модель, как _Kimi K2 Instruct_. + + ```txt + /models + ``` + +--- + +### GitLab Duo + +GitLab Duo предоставляет агентский чат на базе искусственного интеллекта со встроенными возможностями вызова инструментов через прокси-сервер GitLab Anthropic. + +1. Запустите команду `/connect` и выберите GitLab. + + ```txt + /connect + ``` + +2. Выберите метод аутентификации: + + ```txt + ┌ Select auth method + │ + │ OAuth (Recommended) + │ Personal Access Token + └ + ``` + + #### Использование OAuth (рекомендуется) + + Выберите **OAuth**, и ваш браузер откроется для авторизации. + + #### Использование токена личного доступа + 1. Перейдите в [Настройки пользователя GitLab > Токены доступа](https://gitlab.com/-/user_settings/personal_access_tokens). + 2. Нажмите **Добавить новый токен**. + 3. Имя: `OpenCode`, Области применения: `api` + 4. Скопируйте токен (начинается с `glpat-`) + 5. Введите его в терминал + +3. Запустите команду `/models`, чтобы просмотреть доступные модели. + + ```txt + /models + ``` + + Доступны три модели на основе Claude: + - **duo-chat-haiku-4-5** (по умолчанию) — быстрые ответы на быстрые задачи. + - **duo-chat-sonnet-4-5** — сбалансированная производительность для большинства рабочих процессов. + - **duo-chat-opus-4-5** — Наиболее способен к комплексному анализу. + +:::note +Вы также можете указать переменную среды «GITLAB_TOKEN», если не хотите. +для хранения токена в хранилище аутентификации opencode. +::: + +##### Самостоятельная GitLab + +:::note[примечание о соответствии] +opencode использует небольшую модель для некоторых задач ИИ, таких как создание заголовка сеанса. +По умолчанию он настроен на использование gpt-5-nano, размещенного на Zen. Чтобы заблокировать opencode +чтобы использовать только свой собственный экземпляр, размещенный на GitLab, добавьте следующее в свой +`opencode.json` файл. Также рекомендуется отключить совместное использование сеансов. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "small_model": "gitlab/duo-chat-haiku-4-5", + "share": "disabled" +} +``` + +::: + +Для самостоятельных экземпляров GitLab: + +```bash +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_TOKEN=glpat-... +``` + +Если в вашем экземпляре используется собственный AI-шлюз: + +```bash +GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com +``` + +Или добавьте в свой профиль bash: + +```bash title="~/.bash_profile" +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com +export GITLAB_TOKEN=glpat-... +``` + +:::note +Ваш администратор GitLab должен включить следующее: + +1. [Платформа Duo Agent](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) для пользователя, группы или экземпляра +2. Флаги функций (через консоль Rails): + - `agent_platform_claude_code` + - `third_party_agents_enabled` + ::: + +##### OAuth для локальных экземпляров + +Чтобы Oauth работал на вашем локальном экземпляре, вам необходимо создать +новое приложение (Настройки → Приложения) с +URL обратного вызова `http://127.0.0.1:8080/callback` и следующие области: + +- API (Доступ к API от вашего имени) +- read_user (прочитать вашу личную информацию) +- read_repository (разрешает доступ к репозиторию только для чтения) + +Затем укажите идентификатор приложения как переменную среды: + +```bash +export GITLAB_OAUTH_CLIENT_ID=your_application_id_here +``` + +Дополнительная документация на домашней странице [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth). + +##### Конфигурация + +Настройте через `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "gitlab": { + "options": { + "instanceUrl": "https://gitlab.com" + } + } + } +} +``` + +##### Инструменты API GitLab (необязательно, но настоятельно рекомендуется) + +Чтобы получить доступ к инструментам GitLab (мерж-реквесты, задачи, конвейеры, CI/CD и т. д.): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-gitlab-plugin"] +} +``` + +Этот плагин предоставляет комплексные возможности управления репозиторием GitLab, включая проверки MR, отслеживание проблем, мониторинг конвейера и многое другое. + +--- + +### GitHub Copilot + +Чтобы использовать подписку GitHub Copilot с открытым кодом: + +:::note +Некоторым моделям может потребоваться [Pro+ +подписка](https://github.com/features/copilot/plans) для использования. + +Некоторые модели необходимо включить вручную в настройках [GitHub Copilot](https://docs.github.com/en/copilot/how-tos/use-ai-models/configure-access-to-ai-models#setup-for-individual-use). +::: + +1. Запустите команду `/connect` и найдите GitHub Copilot. + + ```txt + /connect + ``` + +2. Перейдите на [github.com/login/device](https://github.com/login/device) и введите код. + + ```txt + ┌ Login with GitHub Copilot + │ + │ https://github.com/login/device + │ + │ Enter code: 8F43-6FCF + │ + └ Waiting for authorization... + ``` + +3. Теперь запустите команду `/models`, чтобы выбрать нужную модель. + + ```txt + /models + ``` + +--- + +### Google Vertex AI + +Чтобы использовать Google Vertex AI с opencode: + +1. Перейдите в **Model Garden** в Google Cloud Console и проверьте + модели, доступные в вашем регионе. + + :::note + Вам необходим проект Google Cloud с включенным Vertex AI API. + ::: + +2. Установите необходимые переменные среды: + - `GOOGLE_CLOUD_PROJECT`: идентификатор вашего проекта Google Cloud. + - `VERTEX_LOCATION` (необязательно): регион для Vertex AI (по умолчанию `global`). + - Аутентификация (выберите одну): + - `GOOGLE_APPLICATION_CREDENTIALS`: путь к ключевому файлу JSON вашего сервисного аккаунта. + - Аутентификация через CLI gcloud: `gcloud auth application-default login`. + + Установите их во время запуска opencode. + + ```bash + GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json GOOGLE_CLOUD_PROJECT=your-project-id opencode + ``` + + Или добавьте их в свой профиль bash. + + ```bash title="~/.bash_profile" + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json + export GOOGLE_CLOUD_PROJECT=your-project-id + export VERTEX_LOCATION=global + ``` + +:::tip +Регион `global` повышает доступность и уменьшает количество ошибок без дополнительных затрат. Используйте региональные конечные точки (например, `us-central1`) для требований к местонахождению данных. [Подробнее](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models#regional_and_global_endpoints) +::: + +3. Запустите команду `/models`, чтобы выбрать нужную модель. + + ```txt + /models + ``` + +--- + +### Groq + +1. Перейдите в консоль Groq](https://console.groq.com/), нажмите **Создать ключ API** и скопируйте ключ. + +2. Запустите команду `/connect` и найдите Groq. + + ```txt + /connect + ``` + +3. Введите ключ API для провайдера. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать тот, который вам нужен. + + ```txt + /models + ``` + +--- + +### Hugging Face + +[Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers) предоставляют доступ к открытым моделям, поддерживаемым более чем 17 поставщиками. + +1. Перейдите в [Настройки Hugging Face](https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained), чтобы создать токен с разрешением совершать вызовы к поставщикам выводов. + +2. Запустите команду `/connect` и найдите **Hugging Face**. + + ```txt + /connect + ``` + +3. Введите свой токен Hugging Face. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать такую ​​модель, как _Kimi-K2-Instruct_ или _GLM-4.6_. + + ```txt + /models + ``` + +--- + +### Helicone + +[Helicone](https://helicone.ai) — это платформа наблюдения LLM, которая обеспечивает ведение журнала, мониторинг и аналитику для ваших приложений искусственного интеллекта. Helicone AI Gateway автоматически направляет ваши запросы соответствующему поставщику на основе модели. + +1. Перейдите в [Helicone](https://helicone.ai), создайте учетную запись и сгенерируйте ключ API на своей панели управления. + +2. Запустите команду `/connect` и найдите **Helicone**. + + ```txt + /connect + ``` + +3. Введите свой ключ API Helicone. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель. + + ```txt + /models + ``` + +Дополнительные сведения о дополнительных провайдерах и расширенных функциях, таких как кэширование и ограничение скорости, см. в [Документация Helicone](https://docs.helicone.ai). + +#### Дополнительные конфигурации + +Если вы видите функцию или модель от Helicone, которая не настраивается автоматически через opencode, вы всегда можете настроить ее самостоятельно. + +Вот [Справочник моделей Helicone](https://helicone.ai/models), он понадобится вам, чтобы получить идентификаторы моделей, которые вы хотите добавить. + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + }, + "models": { + "gpt-4o": { + // Model ID (from Helicone's model directory page) + "name": "GPT-4o", // Your own custom name for the model + }, + "claude-sonnet-4-20250514": { + "name": "Claude Sonnet 4", + }, + }, + }, + }, +} +``` + +#### Пользовательские заголовки + +Helicone поддерживает пользовательские заголовки для таких функций, как кэширование, отслеживание пользователей и управление сеансами. Добавьте их в конфигурацию вашего провайдера, используя `options.headers`: + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + "headers": { + "Helicone-Cache-Enabled": "true", + "Helicone-User-Id": "opencode", + }, + }, + }, + }, +} +``` + +##### Отслеживание сеансов + +Функция Helicone [Sessions](https://docs.helicone.ai/features/sessions) позволяет группировать связанные запросы LLM вместе. Используйте плагин [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session), чтобы автоматически регистрировать каждый диалог opencode как сеанс в Helicone. + +```bash +npm install -g opencode-helicone-session +``` + +Добавьте его в свою конфигурацию. + +```json title="opencode.json" +{ + "plugin": ["opencode-helicone-session"] +} +``` + +Плагин вставляет в ваши запросы заголовки `Helicone-Session-Id` и `Helicone-Session-Name`. На странице «Сеансы» Helicone вы увидите каждый диалог opencode, указанный как отдельный сеанс. + +##### Общие разъемы Helicone + +| Заголовок | Описание | +| -------------------------- | ------------------------------------------------------------------------------ | +| `Helicone-Cache-Enabled` | Включить кэширование ответов (`true`/`false`) | +| `Helicone-User-Id` | Отслеживание показателей по пользователю | +| `Helicone-Property-[Name]` | Добавьте пользовательские свойства (например, `Helicone-Property-Environment`) | +| `Helicone-Prompt-Id` | Связывание запросов с версиями промптов | + +См. [Справочник заголовков Helicone](https://docs.helicone.ai/helicone-headers/header-directory) для всех доступных заголовков. + +--- + +### llama.cpp + +Вы можете настроить opencode для использования локальных моделей с помощью [утилиты llama-server llama.cpp's](https://github.com/ggml-org/llama.cpp) + +```json title="opencode.json" "llama.cpp" {5, 6, 8, 10-15} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "llama.cpp": { + "npm": "@ai-sdk/openai-compatible", + "name": "llama-server (local)", + "options": { + "baseURL": "http://127.0.0.1:8080/v1" + }, + "models": { + "qwen3-coder:a3b": { + "name": "Qwen3-Coder: a3b-30b (local)", + "limit": { + "context": 128000, + "output": 65536 + } + } + } + } + } +} +``` + +В этом примере: + +- `llama.cpp` — это идентификатор пользовательского поставщика. Это может быть любая строка, которую вы хотите. +- `npm` указывает пакет, который будет использоваться для этого поставщика. Здесь `@ai-sdk/openai-compatible` используется для любого API-интерфейса, совместимого с OpenAI. +- `name` — это отображаемое имя поставщика в пользовательском интерфейсе. +- `options.baseURL` — конечная точка локального сервера. +- `models` — это карта идентификаторов моделей с их конфигурациями. Название модели будет отображаться в списке выбора модели. + +--- + +### IO.NET + +IO.NET предлагает 17 моделей, оптимизированных для различных случаев использования: + +1. Перейдите в консоль IO.NET](https://ai.io.net/), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **IO.NET**. + + ```txt + /connect + ``` + +3. Введите свой ключ API IO.NET. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель. + + ```txt + /models + ``` + +--- + +### LM Studio + +Вы можете настроить opencode для использования локальных моделей через LM Studio. + +```json title="opencode.json" "lmstudio" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "lmstudio": { + "npm": "@ai-sdk/openai-compatible", + "name": "LM Studio (local)", + "options": { + "baseURL": "http://127.0.0.1:1234/v1" + }, + "models": { + "google/gemma-3n-e4b": { + "name": "Gemma 3n-e4b (local)" + } + } + } + } +} +``` + +В этом примере: + +- `lmstudio` — это идентификатор пользовательского поставщика. Это может быть любая строка, которую вы хотите. +- `npm` указывает пакет, который будет использоваться для этого поставщика. Здесь `@ai-sdk/openai-compatible` используется для любого API-интерфейса, совместимого с OpenAI. +- `name` — это отображаемое имя поставщика в пользовательском интерфейсе. +- `options.baseURL` — конечная точка локального сервера. +- `models` — это карта идентификаторов моделей с их конфигурациями. Название модели будет отображаться в списке выбора модели. + +--- + +### Moonshot AI + +Чтобы использовать Кими К2 из Moonshot AI: + +1. Перейдите в [консоль Moonshot AI](https://platform.moonshot.ai/console), создайте учетную запись и нажмите **Создать ключ API**. + +2. Запустите команду `/connect` и найдите **Moonshot AI**. + + ```txt + /connect + ``` + +3. Введите свой API-ключ Moonshot. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать _Kimi K2_. + + ```txt + /models + ``` + +--- + +### MiniMax + +1. Перейдите в [консоль API MiniMax](https://platform.minimax.io/login), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **MiniMax**. + + ```txt + /connect + ``` + +3. Введите свой ключ API MiniMax. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель типа _M2.1_. + + ```txt + /models + ``` + +--- + +### Nebius Token Factory + +1. Перейдите в консоль Nebius Token Factory](https://tokenfactory.nebius.com/), создайте учетную запись и нажмите **Добавить ключ**. + +2. Запустите команду `/connect` и найдите **Nebius Token Factory**. + + ```txt + /connect + ``` + +3. Введите ключ API фабрики токенов Nebius. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать такую ​​модель, как _Kimi K2 Instruct_. + + ```txt + /models + ``` + +--- + +### Ollama + +Вы можете настроить opencode для использования локальных моделей через Ollama. + +:::tip +Ollama может автоматически настроиться для opencode. Подробности см. в документации по интеграции Ollama](https://docs.ollama.com/integrations/opencode). +::: + +```json title="opencode.json" "ollama" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "ollama": { + "npm": "@ai-sdk/openai-compatible", + "name": "Ollama (local)", + "options": { + "baseURL": "http://localhost:11434/v1" + }, + "models": { + "llama2": { + "name": "Llama 2" + } + } + } + } +} +``` + +В этом примере: + +- `ollama` — это идентификатор пользовательского поставщика. Это может быть любая строка, которую вы хотите. +- `npm` указывает пакет, который будет использоваться для этого поставщика. Здесь `@ai-sdk/openai-compatible` используется для любого API-интерфейса, совместимого с OpenAI. +- `name` — это отображаемое имя поставщика в пользовательском интерфейсе. +- `options.baseURL` — конечная точка локального сервера. +- `models` — это карта идентификаторов моделей с их конфигурациями. Название модели будет отображаться в списке выбора модели. + +:::tip +Если вызовы инструментов не работают, попробуйте увеличить `num_ctx` в Олламе. Начните с 16–32 тысяч. +::: + +--- + +### Ollama Cloud + +Чтобы использовать Ollama Cloud с opencode: + +1. Перейдите на [https://ollama.com/](https://ollama.com/) и войдите в систему или создайте учетную запись. + +2. Перейдите в **Настройки** > **Ключи** и нажмите **Добавить ключ API**, чтобы создать новый ключ API. + +3. Скопируйте ключ API для использования в opencode. + +4. Запустите команду `/connect` и найдите **Ollama Cloud**. + + ```txt + /connect + ``` + +5. Введите свой ключ API Ollama Cloud. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +6. **Важно**. Перед использованием облачных моделей в opencode необходимо получить информацию о модели локально: + + ```bash + ollama pull gpt-oss:20b-cloud + ``` + +7. Запустите команду `/models`, чтобы выбрать модель облака Ollama. + + ```txt + /models + ``` + +--- + +### OpenAI + +Мы рекомендуем подписаться на [ChatGPT Plus или Pro](https://chatgpt.com/pricing). + +1. После регистрации выполните команду `/connect` и выберите OpenAI. + + ```txt + /connect + ``` + +2. Здесь вы можете выбрать опцию **ChatGPT Plus/Pro**, и ваш браузер откроется. + и попросите вас пройти аутентификацию. + + ```txt + ┌ Select auth method + │ + │ ChatGPT Plus/Pro + │ Manually enter API Key + └ + ``` + +3. Теперь все модели OpenAI должны быть доступны при использовании команды `/models`. + + ```txt + /models + ``` + +##### Использование ключей API + +Если у вас уже есть ключ API, вы можете выбрать **Ввести ключ API вручную** и вставить его в свой терминал. + +--- + +### OpenCode Zen + +OpenCode Zen — это список протестированных и проверенных моделей, предоставленный командой opencode. [Подробнее](/docs/zen). + +1. Войдите в систему **OpenCode Zen** и нажмите **Создать ключ API**. + +2. Запустите команду `/connect` и найдите **OpenCode Zen**. + + ```txt + /connect + ``` + +3. Введите свой ключ API opencode. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать такую ​​модель, как _Qwen 3 Coder 480B_. + + ```txt + /models + ``` + +--- + +### OpenRouter + +1. Перейдите на панель управления OpenRouter](https://openrouter.ai/settings/keys), нажмите **Создать ключ API** и скопируйте ключ. + +2. Запустите команду `/connect` и найдите OpenRouter. + + ```txt + /connect + ``` + +3. Введите ключ API для провайдера. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Многие модели OpenRouter предварительно загружены по умолчанию. Запустите команду `/models`, чтобы выбрать нужную. + + ```txt + /models + ``` + + Вы также можете добавить дополнительные модели через конфигурацию opencode. + + ```json title="opencode.json" {6} + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "models": { + "somecoolnewmodel": {} + } + } + } + } + ``` + +5. Вы также можете настроить их через конфигурацию opencode. Вот пример указания провайдера + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "models": { + "moonshotai/kimi-k2": { + "options": { + "provider": { + "order": ["baseten"], + "allow_fallbacks": false + } + } + } + } + } + } + } + ``` + +--- + +### SAP AI Core + +SAP AI Core предоставляет доступ к более чем 40 моделям от OpenAI, Anthropic, Google, Amazon, Meta, Mistral и AI21 через единую платформу. + +1. Перейдите в [SAP BTP Cockpit](https://account.hana.ondemand.com/), перейдите к экземпляру службы SAP AI Core и создайте ключ службы. + + :::tip + Ключ службы — это объект JSON, содержащий `clientid`, `clientsecret`, `url` и `serviceurls.AI_API_URL`. Экземпляр AI Core можно найти в разделе **Сервисы** > **Экземпляры и подписки** в панели управления BTP. + ::: + +2. Запустите команду `/connect` и найдите **SAP AI Core**. + + ```txt + /connect + ``` + +3. Введите свой сервисный ключ в формате JSON. + + ```txt + ┌ Service key + │ + │ + └ enter + ``` + + Или установите переменную среды `AICORE_SERVICE_KEY`: + + ```bash + AICORE_SERVICE_KEY='{"clientid":"...","clientsecret":"...","url":"...","serviceurls":{"AI_API_URL":"..."}}' opencode + ``` + + Или добавьте его в свой профиль bash: + + ```bash title="~/.bash_profile" + export AICORE_SERVICE_KEY='{"clientid":"...","clientsecret":"...","url":"...","serviceurls":{"AI_API_URL":"..."}}' + ``` + +4. При необходимости укажите идентификатор развертывания и группу ресурсов: + + ```bash + AICORE_DEPLOYMENT_ID=your-deployment-id AICORE_RESOURCE_GROUP=your-resource-group opencode + ``` + + :::note + Эти параметры являются необязательными и должны быть настроены в соответствии с настройками SAP AI Core. + ::: + +5. Запустите команду `/models`, чтобы выбрать одну из более чем 40 доступных моделей. + + ```txt + /models + ``` + +--- + +### STACKIT + +STACKIT AI Model Serving предоставляет полностью управляемую суверенную среду хостинга для моделей ИИ, ориентированную на LLM, таких как Llama, Mistral и Qwen, с максимальным суверенитетом данных в европейской инфраструктуре. + +1. Перейдите на [портал STACKIT](https://portal.stackit.cloud), перейдите в **AI Model Serving** и создайте токен аутентификации для своего проекта. + + :::tip + Вам необходима учетная запись клиента STACKIT, учетная запись пользователя и проект перед созданием токенов аутентификации. + ::: + +2. Запустите команду `/connect` и найдите **STACKIT**. + + ```txt + /connect + ``` + +3. Введите свой токен аутентификации STACKIT AI Model Serving. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать одну из доступных моделей, например _Qwen3-VL 235B_ или _Llama 3.3 70B_. + + ```txt + /models + ``` + +--- + +### OVHcloud AI Endpoints + +1. Перейдите к [OVHcloud Panel](https://ovh.com/manager). Перейдите в раздел `Public Cloud`, `AI & Machine Learning` > `AI Endpoints` и на вкладке `API Keys` нажмите **Создать новый ключ API**. + +2. Запустите команду `/connect` и найдите **Конечные точки OVHcloud AI**. + + ```txt + /connect + ``` + +3. Введите ключ API конечных точек OVHcloud AI. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель типа _gpt-oss-120b_. + + ```txt + /models + ``` + +--- + +### Scaleway + +Чтобы использовать [Scaleway Generative APIs](https://www.scaleway.com/en/docs/generative-apis/) с opencode: + +1. Перейдите к [Настройки IAM консоли Scaleway](https://console.scaleway.com/iam/api-keys), чтобы сгенерировать новый ключ API. + +2. Запустите команду `/connect` и найдите **Scaleway**. + + ```txt + /connect + ``` + +3. Введите ключ API Scaleway. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель, например _devstral-2-123b-instruct-2512_ или _gpt-oss-120b_. + + ```txt + /models + ``` + +--- + +### Together AI + +1. Перейдите в [консоль Together AI](https://api.together.ai), создайте учетную запись и нажмите **Добавить ключ**. + +2. Запустите команду `/connect` и найдите **Together AI**. + + ```txt + /connect + ``` + +3. Введите ключ API Together AI. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать такую ​​модель, как _Kimi K2 Instruct_. + + ```txt + /models + ``` + +--- + +### Venice AI + +1. Перейдите к [консоли Venice AI](https://venice.ai), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **Venice AI**. + + ```txt + /connect + ``` + +3. Введите свой ключ API Venice AI. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель типа _Llama 3.3 70B_. + + ```txt + /models + ``` + +--- + +### Vercel AI Gateway + +Vercel AI Gateway позволяет получать доступ к моделям OpenAI, Anthropic, Google, xAI и других источников через единую конечную точку. Модели предлагаются по прейскурантной цене без наценок. + +1. Перейдите на [панель мониторинга Vercel](https://vercel.com/), перейдите на вкладку **AI Gateway** и нажмите **Ключи API**, чтобы создать новый ключ API. + +2. Запустите команду `/connect` и найдите **Vercel AI Gateway**. + + ```txt + /connect + ``` + +3. Введите ключ API Vercel AI Gateway. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель. + + ```txt + /models + ``` + +Вы также можете настраивать модели через конфигурацию opencode. Ниже приведен пример указания порядка маршрутизации поставщика. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "vercel": { + "models": { + "anthropic/claude-sonnet-4": { + "options": { + "order": ["anthropic", "vertex"] + } + } + } + } + } +} +``` + +Некоторые полезные параметры маршрутизации: + +| Вариант | Описание | +| ------------------- | -------------------------------------------------------------------- | +| `order` | Последовательность провайдеров для попытки | +| `only` | Ограничить конкретными провайдерами | +| `zeroDataRetention` | Использовать только провайдеров с политикой нулевого хранения данных | + +--- + +### xAI + +1. Перейдите на [консоль xAI](https://console.x.ai/), создайте учетную запись и сгенерируйте ключ API. + +2. Запустите команду `/connect` и найдите **xAI**. + + ```txt + /connect + ``` + +3. Введите свой ключ API xAI. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать такую ​​модель, как _Grok Beta_. + + ```txt + /models + ``` + +--- + +### Z.AI + +1. Перейдите в [консоль Z.AI API](https://z.ai/manage-apikey/apikey-list), создайте учетную запись и нажмите **Создать новый ключ API**. + +2. Запустите команду `/connect` и найдите **Z.AI**. + + ```txt + /connect + ``` + + Если вы подписаны на **План кодирования GLM**, выберите **План кодирования Z.AI**. + +3. Введите свой ключ API Z.AI. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Запустите команду `/models`, чтобы выбрать модель типа _GLM-4.7_. + + ```txt + /models + ``` + +--- + +### ZenMux + +1. Перейдите на [панель управления ZenMux](https://zenmux.ai/settings/keys), нажмите **Создать ключ API** и скопируйте ключ. + +2. Запустите команду `/connect` и найдите ZenMux. + + ```txt + /connect + ``` + +3. Введите ключ API для провайдера. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Многие модели ZenMux предварительно загружены по умолчанию. Запустите команду `/models`, чтобы выбрать нужную. + + ```txt + /models + ``` + + Вы также можете добавить дополнительные модели через конфигурацию opencode. + + ```json title="opencode.json" {6} + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "zenmux": { + "models": { + "somecoolnewmodel": {} + } + } + } + } + ``` + +--- + +## Пользовательский поставщик + +Чтобы добавить любого **совместимого с OpenAI** поставщика, не указанного в команде `/connect`: + +:::tip +Вы можете использовать любого OpenAI-совместимого провайдера с открытым кодом. Большинство современных поставщиков ИИ предлагают API-интерфейсы, совместимые с OpenAI. +::: + +1. Запустите команду `/connect` и прокрутите вниз до пункта **Другое**. + + ```bash + $ /connect + + ┌ Add credential + │ + ◆ Select provider + │ ... + │ ● Other + └ + ``` + +2. Введите уникальный идентификатор провайдера. + + ```bash + $ /connect + + ┌ Add credential + │ + ◇ Enter provider id + │ myprovider + └ + ``` + + :::примечание + Выберите запоминающийся идентификатор, вы будете использовать его в своем файле конфигурации. + ::: + +3. Введите свой ключ API для провайдера. + + ```bash + $ /connect + + ┌ Add credential + │ + ▲ This only stores a credential for myprovider - you will need to configure it in opencode.json, check the docs for examples. + │ + ◇ Enter your API key + │ sk-... + └ + ``` + +4. Создайте или обновите файл `opencode.json` в каталоге вашего проекта: + + ```json title="opencode.json" ""myprovider"" {5-15} + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "myprovider": { + "npm": "@ai-sdk/openai-compatible", + "name": "My AI ProviderDisplay Name", + "options": { + "baseURL": "https://api.myprovider.com/v1" + }, + "models": { + "my-model-name": { + "name": "My Model Display Name" + } + } + } + } + } + ``` + + Вот варианты конфигурации: + - **npm**: используемый пакет AI SDK, `@ai-sdk/openai-compatible` для поставщиков, совместимых с OpenAI. + - **имя**: отображаемое имя в пользовательском интерфейсе. + - **модели**: Доступные модели. + - **options.baseURL**: URL-адрес конечной точки API. + - **options.apiKey**: при необходимости установите ключ API, если не используется аутентификация. + - **options.headers**: при необходимости можно установить собственные заголовки. + + Подробнее о дополнительных параметрах в примере ниже. + +5. Запустите команду `/models`, и ваш пользовательский поставщик и модели появятся в списке выбора. + +--- + +##### Пример + +Ниже приведен пример настройки параметров `apiKey`, `headers` и модели `limit`. + +```json title="opencode.json" {9,11,17-20} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "myprovider": { + "npm": "@ai-sdk/openai-compatible", + "name": "My AI ProviderDisplay Name", + "options": { + "baseURL": "https://api.myprovider.com/v1", + "apiKey": "{env:ANTHROPIC_API_KEY}", + "headers": { + "Authorization": "Bearer custom-token" + } + }, + "models": { + "my-model-name": { + "name": "My Model Display Name", + "limit": { + "context": 200000, + "output": 65536 + } + } + } + } + } +} +``` + +Детали конфигурации: + +- **apiKey**: устанавливается с использованием синтаксиса переменной `env`, [подробнее ](/docs/config#env-vars). +- **заголовки**: пользовательские заголовки, отправляемые с каждым запросом. +- **limit.context**: Максимальное количество входных токенов, которые принимает модель. +- **limit.output**: Максимальное количество токенов, которые может сгенерировать модель. + +Поля `limit` позволяют opencode понять, сколько контекста у вас осталось. Стандартные поставщики автоматически извлекают их из models.dev. + +--- + +## Поиск неисправностей + +Если у вас возникли проблемы с настройкой провайдера, проверьте следующее: + +1. **Проверьте настройку аутентификации**: запустите `opencode auth list`, чтобы проверить, верны ли учетные данные. + для провайдера добавлены в ваш конфиг. + + Это не относится к таким поставщикам, как Amazon Bedrock, которые для аутентификации полагаются на переменные среды. + +2. Для пользовательских поставщиков проверьте конфигурацию opencode и: + - Убедитесь, что идентификатор провайдера, используемый в команде `/connect`, соответствует идентификатору в вашей конфигурации opencode. + - Для провайдера используется правильный пакет npm. Например, используйте `@ai-sdk/cerebras` для Cerebras. А для всех других поставщиков, совместимых с OpenAI, используйте `@ai-sdk/openai-compatible`. + - Убедитесь, что в поле `options.baseURL` используется правильная конечная точка API. diff --git a/packages/web/src/content/docs/ru/rules.mdx b/packages/web/src/content/docs/ru/rules.mdx new file mode 100644 index 0000000000000000000000000000000000000000..d553bd0ced783b3cd0ea5855c774c367f0d5ee27 --- /dev/null +++ b/packages/web/src/content/docs/ru/rules.mdx @@ -0,0 +1,180 @@ +--- +title: Правила +description: Установите пользовательские инструкции для opencode. +--- + +Вы можете предоставить собственные инструкции для opencode, создав файл `AGENTS.md`. Это похоже на правила Cursor. Он содержит инструкции, которые будут включены в контекст LLM для настройки его поведения для вашего конкретного проекта. + +--- + +## Инициализировать + +Чтобы создать новый файл `AGENTS.md`, вы можете запустить команду `/init` в opencode. + +:::tip +Вам следует закоммитить файл `AGENTS.md` вашего проекта в Git. +::: + +Это позволит отсканировать ваш проект и все его содержимое, чтобы понять, о чем этот проект, и сгенерировать с его помощью файл `AGENTS.md`. Это помогает opencode лучше ориентироваться в проекте. + +Если у вас есть существующий файл `AGENTS.md`, мы попытаемся добавить его. + +--- + +## Пример + +Вы также можете просто создать этот файл вручную. Вот пример того, что вы можете поместить в файл `AGENTS.md`. + +```markdown title="AGENTS.md" +# SST v3 Monorepo Project + +This is an SST v3 monorepo with TypeScript. The project uses bun workspaces for package management. + +## Project Structure + +- `packages/` - Contains all workspace packages (functions, core, web, etc.) +- `infra/` - Infrastructure definitions split by service (storage.ts, api.ts, web.ts) +- `sst.config.ts` - Main SST configuration with dynamic imports + +## Code Standards + +- Use TypeScript with strict mode enabled +- Shared code goes in `packages/core/` with proper exports configuration +- Functions go in `packages/functions/` +- Infrastructure should be split into logical files in `infra/` + +## Monorepo Conventions + +- Import shared modules using workspace names: `@my-app/core/example` +``` + +Мы добавляем сюда инструкции для конкретного проекта, и они будут доступны всей вашей команде. + +--- + +## Типы + +opencode также поддерживает чтение файла `AGENTS.md` из нескольких мест. И это служит разным целям. + +### Проект + +Поместите `AGENTS.md` в корень вашего проекта для правил, специфичных для проекта. Они применяются только тогда, когда вы работаете в этом каталоге или его подкаталогах. + +### Глобальный + +Вы также можете иметь глобальные правила в файле `~/.config/opencode/AGENTS.md`. Это применяется ко всем сеансам opencode. + +Поскольку это не коммитится в Git и не передается вашей команде, мы рекомендуем использовать его для указания любых личных правил, которым должен следовать LLM. + +### Совместимость кода Клода + +Для пользователей, переходящих с Claude Code, opencode поддерживает файловые соглашения Claude Code в качестве резерва: + +- **Правила проекта**: `CLAUDE.md` в каталоге вашего проекта (используется, если `AGENTS.md` не существует). +- **Глобальные правила**: `~/.claude/CLAUDE.md` (используется, если `~/.config/opencode/AGENTS.md` не существует). +- **Навыки**: `~/.claude/skills/` — подробности см. в [Навыки агента](/docs/skills/). + +Чтобы отключить совместимость Claude Code, установите одну из этих переменных среды: + +```bash +export OPENCODE_DISABLE_CLAUDE_CODE=1 # Disable all .claude support +export OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1 # Disable only ~/.claude/CLAUDE.md +export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills +``` + +--- + +## Приоритет + +Когда opencode запускается, он ищет файлы правил в следующем порядке: + +1. **Локальные файлы** путем перехода вверх из текущего каталога (`AGENTS.md`, `CLAUDE.md`) +2. **Глобальный файл** в `~/.config/opencode/AGENTS.md`. +3. **Файл кода Клауда** по адресу `~/.claude/CLAUDE.md` (если не отключено) + +Первый совпадающий файл побеждает в каждой категории. Например, если у вас есть и `AGENTS.md`, и `CLAUDE.md`, используется только `AGENTS.md`. Аналогично, `~/.config/opencode/AGENTS.md` имеет приоритет над `~/.claude/CLAUDE.md`. + +--- + +## Пользовательские инструкции + +Вы можете указать собственные файлы инструкций в `opencode.json` или в глобальном `~/.config/opencode/opencode.json`. Это позволит вам и вашей команде повторно использовать существующие правила вместо того, чтобы дублировать их на AGENTS.md. + +Пример: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md"] +} +``` + +Вы также можете использовать удаленные URL-адреса для загрузки инструкций из Интернета. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["https://raw.githubusercontent.com/my-org/shared-rules/main/style.md"] +} +``` + +Удаленные инструкции извлекаются с таймаутом в 5 секунд. + +Все файлы инструкций объединяются с вашими файлами `AGENTS.md`. + +--- + +## Ссылки на внешние файлы + +Хотя opencode не анализирует автоматически ссылки на файлы в `AGENTS.md`, аналогичной функциональности можно добиться двумя способами: + +### Использование opencode.json + +Рекомендуемый подход — использовать поле `instructions` в `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["docs/development-standards.md", "test/testing-guidelines.md", "packages/*/AGENTS.md"] +} +``` + +### Ручные инструкции в AGENTS.md + +Вы можете научить opencode читать внешние файлы, предоставив явные инструкции в файле `AGENTS.md`. Вот практический пример: + +```markdown title="AGENTS.md" +# TypeScript Project Rules + +## External File Loading + +CRITICAL: When you encounter a file reference (e.g., @rules/general.md), use your Read tool to load it on a need-to-know basis. They're relevant to the SPECIFIC task at hand. + +Instructions: + +- Do NOT preemptively load all references - use lazy loading based on actual need +- When loaded, treat content as mandatory instructions that override defaults +- Follow references recursively when needed + +## Development Guidelines + +For TypeScript code style and best practices: @docs/typescript-guidelines.md +For React component architecture and hooks patterns: @docs/react-patterns.md +For REST API design and error handling: @docs/api-standards.md +For testing strategies and coverage requirements: @test/testing-guidelines.md + +## General Guidelines + +Read the following file immediately as it's relevant to all workflows: @rules/general-guidelines.md. +``` + +Такой подход позволяет: + +- Создавайте модульные файлы правил многократного использования. +- Делитесь правилами между проектами с помощью символических ссылок или подмодулей git. +- Сохраняйте AGENTS.md кратким, ссылаясь на подробные инструкции. +- Убедитесь, что opencode загружает файлы только тогда, когда это необходимо для конкретной задачи. + +:::tip +Для монорепозиториев или проектов с общими стандартами использование `opencode.json` с шаблонами glob (например, `packages/*/AGENTS.md`) более удобно в обслуживании, чем инструкции вручную. +::: diff --git a/packages/web/src/content/docs/ru/server.mdx b/packages/web/src/content/docs/ru/server.mdx new file mode 100644 index 0000000000000000000000000000000000000000..2356543dff2e7f302dba8117f61a3959d097feb5 --- /dev/null +++ b/packages/web/src/content/docs/ru/server.mdx @@ -0,0 +1,287 @@ +--- +title: Сервер +description: Взаимодействуйте с сервером opencode через HTTP. +--- + +import config from "../../../../config.mjs" +export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` + +Команда `opencode serve` запускает автономный HTTP-сервер, который предоставляет конечную точку OpenAPI, которую может использовать клиент с открытым кодом. + +--- + +### Использование + +```bash +opencode serve [--port ] [--hostname ] [--cors ] +``` + +#### Параметры + +| Флаг | Описание | По умолчанию | +| --------------- | ------------------------------------------- | ---------------- | +| `--port` | Порт для прослушивания | `4096` | +| `--hostname` | Имя хоста для прослушивания | `127.0.0.1` | +| `--mdns` | Включить обнаружение mDNS | `false` | +| `--mdns-domain` | Пользовательское доменное имя для mDNS | `opencode.local` | +| `--cors` | Разрешенные дополнительные источники (CORS) | `[]` | + +`--cors` можно передать несколько раз: + +```bash +opencode serve --cors http://localhost:5173 --cors https://app.example.com +``` + +--- + +### Аутентификация + +Установите `OPENCODE_SERVER_PASSWORD`, чтобы защитить сервер с помощью базовой аутентификации HTTP. Имя пользователя по умолчанию — `opencode` или установите `OPENCODE_SERVER_USERNAME`, чтобы переопределить его. Это относится как к `opencode serve`, так и к `opencode web`. + +```bash +OPENCODE_SERVER_PASSWORD=your-password opencode serve +``` + +--- + +### Как это работает + +Когда вы запускаете `opencode`, он запускает TUI и сервер. Где находится TUI +клиент, который общается с сервером. Сервер предоставляет спецификацию OpenAPI 3.1. +конечная точка. Эта конечная точка также используется для создания файла [SDK](/docs/sdk). + +:::tip +Используйте сервер opencode для программного взаимодействия с открытым кодом. +::: + +Эта архитектура позволяет открытому коду поддерживать несколько клиентов и позволяет программно взаимодействовать с открытым кодом. + +Вы можете запустить `opencode serve`, чтобы запустить автономный сервер. Если у вас есть +TUI с открытым кодом запущен, `opencode serve` запустит новый сервер. + +--- + +#### Подключиться к существующему серверу + +Когда вы запускаете TUI, он случайным образом назначает порт и имя хоста. Вместо этого вы можете передать `--hostname` и `--port` [flags](/docs/cli). Затем используйте это для подключения к его серверу. + +Конечную точку [`/tui`](#tui) можно использовать для управления TUI через сервер. Например, вы можете предварительно заполнить или запустить подсказку. Эта настройка используется плагинами opencode [IDE](/docs/ide). + +--- + +## Спецификация + +Сервер публикует спецификацию OpenAPI 3.1, которую можно просмотреть по адресу: + +``` +http://:/doc +``` + +For example, `http://localhost:4096/doc`. Use the spec to generate clients or inspect request and response types. Or view it in a Swagger explorer. + +--- + +## API + +Сервер opencode предоставляет следующие API. + +--- + +### Глобальный + +| Метод | Путь | Описание | Ответ | +| ----- | ---------------- | --------------------------------------- | ------------------------------------ | +| `GET` | `/global/health` | Получить состояние и версию сервера | `{ healthy: true, version: string }` | +| `GET` | `/global/event` | Получить глобальные события (поток SSE) | Поток событий | + +--- + +### Проект + +| Метод | Путь | Описание | Ответ | +| ----- | ------------------ | ----------------------- | --------------------------------------------- | +| `GET` | `/project` | Список всех проектов | Project[] | +| `GET` | `/project/current` | Получить текущий проект | Project | + +--- + +### Путь и система контроля версий + +| Метод | Путь | Описание | Ответ | +| ----- | ------- | ---------------------------------------------- | ------------------------------------------- | +| `GET` | `/path` | Получить текущий путь | Path | +| `GET` | `/vcs` | Получить информацию о VCS для текущего проекта | VcsInfo | + +--- + +### Экземпляр + +| Метод | Путь | Описание | Ответ | +| ------ | ------------------- | ------------------------- | --------- | +| `POST` | `/instance/dispose` | Удалить текущий экземпляр | `boolean` | + +--- + +### Конфигурация + +| Метод | Путь | Описание | Ответ | +| ------- | ------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------- | +| `GET` | `/config` | Получить информацию о конфигурации | Config | +| `PATCH` | `/config` | Обновить конфигурацию | Config | +| `GET` | `/config/providers` | Список провайдеров и моделей по умолчанию | `{ providers: `Provider[]`, default: { [key: string]: string } }` | + +--- + +### Поставщик + +| Метод | Путь | Описание | Ответ | +| ------ | -------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------- | +| `GET` | `/provider` | Список всех провайдеров | `{ all: `Provider[]`, default: {...}, connected: string[] }` | +| `GET` | `/provider/auth` | Получить методы аутентификации провайдера | `{ [providerID: string]: `ProviderAuthMethod[]` }` | +| `POST` | `/provider/{id}/oauth/authorize` | Авторизация провайдера через OAuth | ProviderAuthAuthorization | +| `POST` | `/provider/{id}/oauth/callback` | Обработка callback OAuth для провайдера | `boolean` | + +--- + +### Сессии + +| Метод | Путь | Описание | Примечания | +| -------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------- | +| `GET` | `/session` | Список всех сессий | Возвращает Session[] | +| `POST` | `/session` | Создать новую сессию | body: `{ parentID?, title? }`, возвращает Session | +| `GET` | `/session/status` | Получить статус всех сессий | Возвращает `{ [sessionID: string]: `SessionStatus` }` | +| `GET` | `/session/:id` | Получить детали сессии | Возвращает Session | +| `DELETE` | `/session/:id` | Удалить сессию и все её данные | Возвращает `boolean` | +| `PATCH` | `/session/:id` | Обновить свойства сессии | body: `{ title? }`, возвращает Session | +| `GET` | `/session/:id/children` | Получить дочерние сессии | Возвращает Session[] | +| `GET` | `/session/:id/todo` | Получить список задач для сессии | Возвращает Todo[] | +| `POST` | `/session/:id/init` | Анализ приложения и создание `AGENTS.md` | body: `{ messageID, providerID, modelID }`, возвращает `boolean` | +| `POST` | `/session/:id/fork` | Ответвление сессии от сообщения | body: `{ messageID? }`, возвращает Session | +| `POST` | `/session/:id/abort` | Прервать запущенную сессию | Возвращает `boolean` | +| `POST` | `/session/:id/share` | Поделиться сессией | Возвращает Session | +| `DELETE` | `/session/:id/share` | Отменить общий доступ к сессии | Возвращает Session | +| `GET` | `/session/:id/diff` | Получить diff для этой сессии | query: `messageID?`, возвращает FileDiff[] | +| `POST` | `/session/:id/summarize` | Суммировать сессию | body: `{ providerID, modelID }`, возвращает `boolean` | +| `POST` | `/session/:id/revert` | Отменить сообщение | body: `{ messageID, partID? }`, возвращает `boolean` | +| `POST` | `/session/:id/unrevert` | Восстановить все отмененные сообщения | Возвращает `boolean` | +| `POST` | `/session/:id/permissions/:permissionID` | Ответить на запрос разрешения | body: `{ response, remember? }`, возвращает `boolean` | + +--- + +### Сообщения + +| Метод | Путь | Описание | Примечания | +| ------ | --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/session/:id/message` | Список сообщений в сессии | query: `limit?`, возвращает `{ info: `Message`, parts: `Part[]`}[]` | +| `POST` | `/session/:id/message` | Отправить сообщение и ждать ответа | body: `{ messageID?, model?, agent?, noReply?, system?, tools?, parts }`, возвращает `{ info: `Message`, parts: `Part[]`}` | +| `GET` | `/session/:id/message/:messageID` | Получить детали сообщения | Возвращает `{ info: `Message`, parts: `Part[]`}` | +| `POST` | `/session/:id/prompt_async` | Отправить сообщение асинхронно (без ожидания) | body: как в `/session/:id/message`, возвращает `204 No Content` | +| `POST` | `/session/:id/command` | Выполнить слэш-команду | body: `{ messageID?, agent?, model?, command, arguments }`, возвращает `{ info: `Message`, parts: `Part[]`}` | +| `POST` | `/session/:id/shell` | Запустить команду оболочки | body: `{ agent, model?, command }`, возвращает `{ info: `Message`, parts: `Part[]`}` | + +--- + +### Команды + +| Метод | Путь | Описание | Ответ | +| ----- | ---------- | ------------------ | --------------------------------------------- | +| `GET` | `/command` | Список всех команд | Command[] | + +--- + +### Файлы + +| Метод | Путь | Описание | Ответ | +| ----- | ------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------- | +| `GET` | `/find?pattern=` | Поиск текста в файлах | Массив объектов совпадения с `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | +| `GET` | `/find/file?query=` | Поиск файлов и директорий по имени | `string[]` (пути) | +| `GET` | `/find/symbol?query=` | Поиск символов рабочего пространства | Symbol[] | +| `GET` | `/file?path=` | Список файлов и директорий | FileNode[] | +| `GET` | `/file/content?path=

` | Прочитать файл | FileContent | +| `GET` | `/file/status` | Получить статус отслеживаемых файлов | File[] | + +#### `/find/file` параметры запроса + +- `query` (обязательно) — строка поиска (нечеткое совпадение) +- `type` (необязательно) — ограничить результаты `"file"` или `"directory"`. +- `directory` (необязательно) — переопределить корень проекта для поиска. +- `limit` (необязательно) — максимальное количество результатов (1–200) +- `dirs` (необязательно) — устаревший флаг (`"false"` возвращает только файлы) + +--- + +### Инструменты (Экспериментальные) + +| Метод | Путь | Описание | Ответ | +| ----- | ------------------------------------------- | ---------------------------------------------- | -------------------------------------------- | +| `GET` | `/experimental/tool/ids` | Список всех идентификаторов инструментов | ToolIDs | +| `GET` | `/experimental/tool?provider=

&model=` | Список инструментов со схемами JSON для модели | ToolList | + +--- + +### LSP, форматтеры и MCP + +| Метод | Путь | Описание | Ответ | +| ------ | ------------ | ------------------------------- | -------------------------------------------------------- | +| `GET` | `/lsp` | Получить статус сервера LSP | LSPStatus[] | +| `GET` | `/formatter` | Получить статус форматера | FormatterStatus[] | +| `GET` | `/mcp` | Получить статус сервера MCP | `{ [name: string]: `MCPStatus` }` | +| `POST` | `/mcp` | Добавить сервер MCP динамически | body: `{ name, config }`, возвращает статус объекта MCP | + +--- + +### Агенты + +| Метод | Путь | Описание | Ответ | +| ----- | -------- | ----------------------------- | ------------------------------------------- | +| `GET` | `/agent` | Список всех доступных агентов | Agent[] | + +--- + +### Ведение журнала + +| Метод | Путь | Описание | Ответ | +| ------ | ------ | --------------------------------------------------------------------- | --------- | +| `POST` | `/log` | Записать запись в журнал. Body: `{ service, level, message, extra? }` | `boolean` | + +--- + +### TUI + +| Метод | Путь | Описание | Ответ | +| ------ | ----------------------- | ----------------------------------------------------- | ------------------------- | +| `POST` | `/tui/append-prompt` | Добавить текст в подсказку | `boolean` | +| `POST` | `/tui/open-help` | Открыть диалог помощи | `boolean` | +| `POST` | `/tui/open-sessions` | Открыть селектор сессий | `boolean` | +| `POST` | `/tui/open-themes` | Открыть селектор тем | `boolean` | +| `POST` | `/tui/open-models` | Открыть селектор моделей | `boolean` | +| `POST` | `/tui/submit-prompt` | Отправить текущую подсказку | `boolean` | +| `POST` | `/tui/clear-prompt` | Очистить подсказку | `boolean` | +| `POST` | `/tui/execute-command` | Выполнить команду (`{ command }`) | `boolean` | +| `POST` | `/tui/show-toast` | Показать уведомление (`{ title?, message, variant }`) | `boolean` | +| `GET` | `/tui/control/next` | Ожидание следующего запроса управления | Объект запроса управления | +| `POST` | `/tui/control/response` | Ответить на запрос управления (`{ body }`) | `boolean` | + +--- + +### Авторизация + +| Метод | Путь | Описание | Ответ | +| ----- | ----------- | -------------------------------------------------------------------------------------- | --------- | +| `PUT` | `/auth/:id` | Установить учетные данные аутентификации. Body должен соответствовать схеме провайдера | `boolean` | + +--- + +### События + +| Метод | Путь | Описание | Ответ | +| ----- | -------- | --------------------------------------------------------------------------------------------- | ------------------------------------ | +| `GET` | `/event` | Поток событий, отправляемых сервером. Первое событие — `server.connected`, затем события шины | Поток событий, отправляемых сервером | + +--- + +### Документы + +| Метод | Путь | Описание | Ответ | +| ----- | ------ | ------------------------ | -------------------------------------- | +| `GET` | `/doc` | Спецификация OpenAPI 3.1 | HTML-страница со спецификацией OpenAPI | diff --git a/packages/web/src/content/docs/ru/share.mdx b/packages/web/src/content/docs/ru/share.mdx new file mode 100644 index 0000000000000000000000000000000000000000..8982afb08dfb58a5ce8efa67ac7ea3ea5441ecc5 --- /dev/null +++ b/packages/web/src/content/docs/ru/share.mdx @@ -0,0 +1,128 @@ +--- +title: Делиться +description: Поделитесь своими разговорами об opencode. +--- + +Функция общего доступа opencode позволяет вам создавать общедоступные ссылки на ваши беседы opencode, чтобы вы могли сотрудничать с товарищами по команде или получать помощь от других. + +:::note +Общие беседы общедоступны для всех, у кого есть ссылка. +::: + +--- + +## Как это работает + +Когда вы делитесь беседой, opencode: + +1. Создает уникальный общедоступный URL-адрес для вашего сеанса. +2. Синхронизирует историю ваших разговоров с нашими серверами +3. Делает беседу доступной по общей ссылке — `opncd.ai/s/`. + +--- + +## Совместное использование + +opencode поддерживает три режима общего доступа, которые контролируют общий доступ к разговорам: + +--- + +### Ручной (по умолчанию) + +По умолчанию opencode использует режим совместного использования вручную. Сессии не передаются автоматически, но вы можете поделиться ими вручную с помощью команды `/share`: + +``` +/share +``` + +Это создаст уникальный URL-адрес, который будет скопирован в буфер обмена. + +Чтобы явно установить ручной режим в вашем [файле конфигурации](/docs/config): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "manual" +} +``` + +--- + +### Автоматическая публикация + +Вы можете включить автоматический общий доступ для всех новых разговоров, установив для параметра `share` значение `"auto"` в вашем [файле конфигурации](/docs/config): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "auto" +} +``` + +Если функция автоматического обмена включена, каждый новый разговор будет автоматически опубликован и будет создана ссылка. + +--- + +### Отключено + +Вы можете полностью отключить общий доступ, установив для параметра `share` значение `"disabled"` в вашем [файле конфигурации](/docs/config): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled" +} +``` + +Чтобы обеспечить соблюдение этого правила для всей вашей команды в конкретном проекте, добавьте его в `opencode.json` вашего проекта и зарегистрируйтесь в Git. + +--- + +## Отменить совместное использование + +Чтобы прекратить делиться беседой и удалить ее из общего доступа: + +``` +/unshare +``` + +Это приведет к удалению ссылки общего доступа и удалению данных, связанных с разговором. + +--- + +## Конфиденциальность + +Есть несколько вещей, которые следует учитывать при общении. + +--- + +### Хранение данных + +Общие разговоры остаются доступными до тех пор, пока вы явно не отмените общий доступ к ним. Этот +включает в себя: + +- Полная история разговоров +- Все сообщения и ответы +- Метаданные сеанса + +--- + +### Рекомендации + +- Делитесь только разговорами, которые не содержат конфиденциальной информации. +- Прежде чем поделиться, просмотрите содержимое разговора. +- Отмените общий доступ к разговорам после завершения сотрудничества. +- Избегайте обмена разговорами с проприетарным кодом или конфиденциальными данными. +- Для конфиденциальных проектов полностью отключите общий доступ. + +--- + +## Для предприятий + +Для корпоративных развертываний функция общего доступа может быть: + +- **Отключено** полностью из соображений безопасности. +- **Доступно только** для пользователей, прошедших аутентификацию посредством единого входа. +- **Автономное размещение** в вашей собственной инфраструктуре + +[Узнайте больше](/docs/enterprise) об использовании opencode в вашей организации. diff --git a/packages/web/src/content/docs/ru/skills.mdx b/packages/web/src/content/docs/ru/skills.mdx new file mode 100644 index 0000000000000000000000000000000000000000..152e48782bc2f523a18bc18ffce3af2112207c2d --- /dev/null +++ b/packages/web/src/content/docs/ru/skills.mdx @@ -0,0 +1,222 @@ +--- +title: Навыки агента +description: Определите повторно используемое поведение с помощью определений SKILL.md +--- + +Навыки агента позволяют opencode обнаруживать многократно используемые инструкции из вашего репозитория или домашнего каталога. +Навыки загружаются по требованию с помощью встроенного инструмента `skill`: агенты видят доступные навыки и при необходимости могут загрузить весь контент. + +--- + +## Разместить файлы + +Создайте одну папку для каждого имени навыка и поместите в нее `SKILL.md`. +opencode выполняет поиск в следующих местах: + +- Конфигурация проекта: `.opencode/skills//SKILL.md` +- Глобальная конфигурация: `~/.config/opencode/skills//SKILL.md`. +- Совместимость с Project Claude: `.claude/skills//SKILL.md` +- Глобальная совместимость с Claude: `~/.claude/skills//SKILL.md` +- Совместимость с агентом проекта: `.agents/skills//SKILL.md` +- Совместимость с глобальным агентом: `~/.agents/skills//SKILL.md` + +--- + +## Понимание обнаружения + +Для локальных путей проекта opencode переходит из вашего текущего рабочего каталога, пока не достигнет рабочего дерева git. +Он загружает все соответствующие `skills/*/SKILL.md` в `.opencode/` и все соответствующие `.claude/skills/*/SKILL.md` или `.agents/skills/*/SKILL.md` по пути. + +Глобальные определения также загружаются из `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md` и `~/.agents/skills/*/SKILL.md`. + +--- + +## Напишите заголовок + +Каждый `SKILL.md` должен начинаться с заголовка YAML. +Распознаются только эти поля: + +- `name` (required) +- `description` (required) +- `license` (необязательно) +- `compatibility` (необязательно) +- `metadata` (необязательно, преобразование строк в строки) + +Неизвестные поля заголовка игнорируются. + +--- + +## Проверка имен + +`name` должен: + +- Длина от 1 до 64 символов. +- Используйте строчные буквы и цифры с одинарным дефисом. +- Не начинаться и не заканчиваться на `-`. +- Не содержать последовательных `--` +- Сопоставьте имя каталога, содержащее `SKILL.md`. + +Эквивалентное регулярное выражение: + +```text +^[a-z0-9]+(-[a-z0-9]+)*$ +``` + +--- + +## Соблюдайте правила длины + +`description` должно содержать от 1 до 1024 символов. +Держите его достаточно конкретным, чтобы агент мог сделать правильный выбор. + +--- + +## Используйте пример + +Создайте `.opencode/skills/git-release/SKILL.md` следующим образом: + +```markdown +--- +name: git-release +description: Create consistent releases and changelogs +license: MIT +compatibility: opencode +metadata: + audience: maintainers + workflow: github +--- + +## What I do + +- Draft release notes from merged PRs +- Propose a version bump +- Provide a copy-pasteable `gh release create` command + +## When to use me + +Use this when you are preparing a tagged release. +Ask clarifying questions if the target versioning scheme is unclear. +``` + +--- + +## Распознавание описания инструмента + +opencode перечисляет доступные навыки в описании инструмента `skill`. +Каждая запись включает название и описание навыка: + +```xml + + + git-release + Create consistent releases and changelogs + + +``` + +Агент загружает навык, вызывая инструмент: + +``` +skill({ name: "git-release" }) +``` + +--- + +## Настройка разрешений + +Контролируйте, к каким навыкам агенты могут получить доступ, используя разрешения на основе шаблонов в `opencode.json`: + +```json +{ + "permission": { + "skill": { + "*": "allow", + "pr-review": "allow", + "internal-*": "deny", + "experimental-*": "ask" + } + } +} +``` + +| Разрешение | Поведение | +| ---------- | ----------------------------------------- | +| `allow` | Skill loads immediately | +| `deny` | Skill hidden from agent, access rejected | +| `ask` | User prompted for approval before loading | + +Шаблоны поддерживают подстановочные знаки: `internal-*` соответствует `internal-docs`, `internal-tools` и т. д. + +--- + +## Переопределить для каждого агента + +Предоставьте конкретным агентам разрешения, отличные от глобальных настроек по умолчанию. + +**Для пользовательских агентов** (в заголовке агента): + +```yaml +--- +permission: + skill: + "documents-*": "allow" +--- +``` + +**Для встроенных агентов** (в формате `opencode.json`): + +```json +{ + "agent": { + "plan": { + "permission": { + "skill": { + "internal-*": "allow" + } + } + } + } +} +``` + +--- + +## Отключить инструмент навыков + +Полностью отключить навыки для агентов, которым не следует их использовать: + +**Для индивидуальных агентов**: + +```yaml +--- +tools: + skill: false +--- +``` + +**Для встроенных агентов**: + +```json +{ + "agent": { + "plan": { + "tools": { + "skill": false + } + } + } +} +``` + +Если этот параметр отключен, раздел `` полностью опускается. + +--- + +## Устранение неполадок с загрузкой + +Если навык не отображается: + +1. Убедитесь, что `SKILL.md` написано заглавными буквами. +2. Убедитесь, что заголовок включает `name` и `description`. +3. Убедитесь, что названия навыков уникальны во всех локациях. +4. Проверьте разрешения — навыки с `deny` скрыты от агентов. diff --git a/packages/web/src/content/docs/ru/themes.mdx b/packages/web/src/content/docs/ru/themes.mdx new file mode 100644 index 0000000000000000000000000000000000000000..05ace2c7b8072a62c166e862c29025c9458f54da --- /dev/null +++ b/packages/web/src/content/docs/ru/themes.mdx @@ -0,0 +1,369 @@ +--- +title: Темы +description: Выберите встроенную тему или определите свою собственную. +--- + +С помощью opencode вы можете выбрать одну из нескольких встроенных тем, использовать тему, которая адаптируется к теме вашего терминала, или определить свою собственную тему. + +По умолчанию opencode использует нашу собственную тему `opencode`. + +--- + +## Требования к терминалу + +Чтобы темы корректно отображались в полной цветовой палитре, ваш терминал должен поддерживать **truecolor** (24-битный цвет). Большинство современных терминалов поддерживают это по умолчанию, но вам может потребоваться включить его: + +- **Проверьте поддержку**: запустите `echo $COLORTERM` — должен появиться `truecolor` или `24bit`. +- **Включить truecolor**: установите переменную среды `COLORTERM=truecolor` в профиле shell. +- **Совместимость терминала**: убедитесь, что ваш эмулятор терминала поддерживает 24-битный цвет (большинство современных терминалов, таких как iTerm2, Alacritty, Kitty, Windows Terminal и последние версии GNOME Terminal, поддерживают). + +Без поддержки truecolor темы могут отображаться с пониженной точностью цветопередачи или вернуться к ближайшему приближению к 256 цветам. + +--- + +## Встроенные темы + +opencode поставляется с несколькими встроенными темами. + +| Имя | Описание | +| ---------------------- | ---------------------------------------------------------------------------- | +| `system` | Адаптируется к фоновому цвету терминала | +| `tokyonight` | Based on the [tokyonight](https://github.com/folke/tokyonight.nvim) theme | +| `everforest` | Based on the [Everforest](https://github.com/sainnhe/everforest) theme | +| `ayu` | Based on the [Ayu](https://github.com/ayu-theme) dark theme | +| `catppuccin` | Based on the [Catppuccin](https://github.com/catppuccin) theme | +| `catppuccin-macchiato` | Based on the [Catppuccin](https://github.com/catppuccin) theme | +| `gruvbox` | Based on the [Gruvbox](https://github.com/morhetz/gruvbox) theme | +| `kanagawa` | Based on the [Kanagawa](https://github.com/rebelot/kanagawa.nvim) theme | +| `nord` | Based on the [Nord](https://github.com/nordtheme/nord) theme | +| `matrix` | Хакерская тема: зеленый на черном | +| `one-dark` | Based on the [Atom One](https://github.com/Th3Whit3Wolf/one-nvim) Dark theme | + +И более того, мы постоянно добавляем новые темы. + +--- + +## Системная тема + +Тема `system` автоматически адаптируется к цветовой схеме вашего терминала. В отличие от традиционных тем, использующих фиксированные цвета, тема _system_: + +- **Создает шкалу серого**: создает пользовательскую шкалу серого на основе цвета фона вашего терминала, обеспечивая оптимальный контраст. +- **Использует цвета ANSI**: использует стандартные цвета ANSI (0–15) для подсветки синтаксиса и элементов пользовательского интерфейса, которые соответствуют цветовой палитре вашего терминала. +- **Сохраняет настройки терминала по умолчанию**: использует `none` для цветов текста и фона, чтобы сохранить естественный вид вашего терминала. + +Системная тема предназначена для пользователей, которые: + +- Хотите, чтобы opencode соответствовал внешнему виду их терминала +- Используйте пользовательские цветовые схемы терминала +- Предпочитайте единообразный вид для всех терминальных приложений. + +--- + +## Использование темы + +Вы можете выбрать тему, вызвав выбор темы с помощью команды `/theme`. Или вы можете указать это в файле [tui.json](/docs/config#tui). + +```json title="tui.json" {3} +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "tokyonight" +} +``` + +--- + +## Пользовательские темы + +opencode поддерживает гибкую систему тем на основе JSON, которая позволяет пользователям легко создавать и настраивать темы. + +--- + +### Иерархия + +Темы загружаются из нескольких каталогов в следующем порядке: более поздние каталоги переопределяют предыдущие: + +1. **Встроенные темы** – они встроены в двоичный файл. +2. **Каталог конфигурации пользователя** – определяется в `~/.config/opencode/themes/*.json` или `$XDG_CONFIG_HOME/opencode/themes/*.json`. +3. **Корневой каталог проекта** – определено в `/.opencode/themes/*.json`. +4. **Текущий рабочий каталог** – определено в `./.opencode/themes/*.json`. + +Если несколько каталогов содержат тему с одинаковым именем, будет использоваться тема из каталога с более высоким приоритетом. + +--- + +### Создание темы + +Чтобы создать собственную тему, создайте файл JSON в одном из каталогов темы. + +Для глобальных тем: + +```bash no-frame +mkdir -p ~/.config/opencode/themes +vim ~/.config/opencode/themes/my-theme.json +``` + +Для тем проекта: + +```bash no-frame +mkdir -p .opencode/themes +vim .opencode/themes/my-theme.json +``` + +--- + +### Формат JSON + +В темах используется гибкий формат JSON с поддержкой: + +- **Шестнадцатеричные цвета**: `"#ffffff"` +- **Цвета ANSI**: `3` (0–255). +- **Ссылки на цвета**: `"primary"` или пользовательские определения. +- **Темный/светлый варианты**: `{"dark": "#000", "light": "#fff"}` +- **Нет цвета**: `"none"` — используется цвет терминала по умолчанию или прозрачный. + +--- + +### Определения цвета + +Раздел `defs` является необязательным и позволяет вам определять повторно используемые цвета, на которые можно ссылаться в теме. + +--- + +### Настройки терминала по умолчанию + +Специальное значение `"none"` можно использовать для любого цвета, чтобы наследовать цвет терминала по умолчанию. Это особенно полезно для создания тем, которые органично сочетаются с цветовой схемой вашего терминала: + +- `"text": "none"` — использует цвет переднего плана терминала по умолчанию. +- `"background": "none"` — использует цвет фона терминала по умолчанию. + +--- + +### Пример + +Вот пример пользовательской темы: + +```json title="my-theme.json" +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD" + }, + "theme": { + "primary": { + "dark": "nord8", + "light": "nord10" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord4", + "light": "nord0" + }, + "textMuted": { + "dark": "nord3", + "light": "nord1" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord1", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "nord3", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "nord3", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "nord2", + "light": "nord4" + }, + "diffAddedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "nord3", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "nord3", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "nord3", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" + } + } +} +``` diff --git a/packages/web/src/content/docs/ru/troubleshooting.mdx b/packages/web/src/content/docs/ru/troubleshooting.mdx new file mode 100644 index 0000000000000000000000000000000000000000..d1d240cb713ae16c93b72ea4d4cc9f3c12f57e7a --- /dev/null +++ b/packages/web/src/content/docs/ru/troubleshooting.mdx @@ -0,0 +1,300 @@ +--- +title: Поиск неисправностей +description: Распространенные проблемы и способы их решения. +--- + +Чтобы устранить проблемы с opencode, начните с проверки журналов и локальных данных, которые он хранит на диске. + +--- + +## Журналы + +Лог-файлы записываются в: + +- **macOS/Linux**: `~/.local/share/opencode/log/` +- **Windows**: нажмите `WIN+R` и вставьте `%USERPROFILE%\.local\share\opencode\log`. + +Файлам журналов присваиваются имена с метками времени (например, `2025-01-09T123456.log`), и сохраняются 10 последних файлов журналов. + +Вы можете установить уровень журнала с помощью CLI-параметра `--log-level`, чтобы получить более подробную информацию об отладке. Например, `opencode --log-level DEBUG`. + +--- + +## Хранилище + +opencode хранит данные сеанса и другие данные приложения на диске по адресу: + +- **macOS/Linux**: `~/.local/share/opencode/` +- **Windows**: нажмите `WIN+R` и вставьте `%USERPROFILE%\.local\share\opencode`. + +Этот каталог содержит: + +- `auth.json` – данные аутентификации, такие как ключи API и токены OAuth. +- `log/` – журналы приложений. +- `project/` — данные, специфичные для проекта, такие как данные сеанса и сообщения. + - Если проект находится в репозитории Git, он хранится в `.//storage/`. + - Если это не репозиторий Git, он хранится в `./global/storage/`. + +--- + +## Настольное приложение + +opencode Desktop запускает локальный сервер opencode (спутник `opencode-cli`) в фоновом режиме. Большинство проблем вызвано неправильно работающим плагином, поврежденным кешем или неверными настройками сервера. + +### Быстрые проверки + +- Полностью закройте и перезапустите приложение. +- Если приложение отображает экран с ошибкой, нажмите **Перезапустить** и скопируйте сведения об ошибке. +- Только для macOS: меню `OpenCode` -> **Обновить веб-просмотр** (помогает, если пользовательский интерфейс пуст или завис). + +--- + +### Отключить плагины + +Если настольное приложение дает сбой при запуске, зависает или ведет себя странно, начните с отключения плагинов. + +#### Проверьте глобальную конфигурацию + +Откройте файл глобальной конфигурации и найдите ключ `plugin`. + +- **macOS/Linux**: `~/.config/opencode/opencode.jsonc` (или `~/.config/opencode/opencode.json`) +- **macOS/Linux** (более ранние версии): `~/.local/share/opencode/opencode.jsonc` +- **Windows**: нажмите `WIN+R` и вставьте `%USERPROFILE%\.config\opencode\opencode.jsonc`. + +Если у вас настроены плагины, временно отключите их, удалив ключ или установив для него пустой массив: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [], +} +``` + +#### Проверьте каталоги плагинов + +opencode также может загружать локальные плагины с диска. Временно переместите их в сторону (или переименуйте папку) и перезапустите настольное приложение: + +- **Глобальные плагины** + - **macOS/Linux**: `~/.config/opencode/plugins/` + - **Windows**: нажмите `WIN+R` и вставьте `%USERPROFILE%\.config\opencode\plugins`. +- **Плагины проекта** (только если вы используете конфигурацию для каждого проекта) + - `/.opencode/plugins/` + +Если приложение снова начнет работать, повторно включите плагины по одному, чтобы определить, какой из них вызывает проблему. + +--- + +### Очистить кеш + +Если отключение плагинов не помогает (или установка плагина зависла), очистите кеш, чтобы opencode мог его пересобрать. + +1. Полностью закройте opencode Desktop. +2. Удалите каталог кэша: + +- **macOS**: Finder -> `Cmd+Shift+G` -> вставить `~/.cache/opencode`. +- **Linux**: удалите `~/.cache/opencode` (или запустите `rm -rf ~/.cache/opencode`). +- **Windows**: нажмите `WIN+R` и вставьте `%USERPROFILE%\.cache\opencode`. + +3. Перезапустите рабочий стол opencode. + +--- + +### Исправить проблемы с подключением к серверу + +opencode Desktop может либо запустить собственный локальный сервер (по умолчанию), либо подключиться к настроенному вами URL-адресу сервера. + +Если вы видите диалоговое окно **Ошибка подключения** (или приложение никогда не выходит за пределы заставки), проверьте URL-адрес пользовательского сервера. + +#### Очистите URL-адрес сервера по умолчанию для рабочего стола. + +На главном экране щелкните имя сервера (с точкой состояния), чтобы открыть окно выбора сервера. В разделе **Сервер по умолчанию** нажмите **Очистить**. + +#### Удалите `server.port`/`server.hostname` из вашей конфигурации. + +Если ваш `opencode.json(c)` содержит раздел `server`, временно удалите его и перезапустите настольное приложение. + +#### Проверьте переменные среды + +Если в вашей среде установлен `OPENCODE_PORT`, настольное приложение попытается использовать этот порт для локального сервера. + +- Отмените настройку `OPENCODE_PORT` (или выберите свободный порт) и перезапустите. + +--- + +### Linux: проблемы с Wayland/X11 + +В Linux некоторые настройки Wayland могут вызывать пустые окна или ошибки компоновщика. + +- Если вы используете Wayland, а приложение не работает или вылетает, попробуйте запустить с помощью `OC_ALLOW_WAYLAND=1`. +- Если это усугубляет ситуацию, удалите его и попробуйте вместо этого запустить сеанс X11. + +--- + +### Windows: среда выполнения WebView2. + +В Windows для opencode Desktop требуется Microsoft Edge **WebView2 Runtime**. Если приложение открывается в пустом окне или не запускается, установите/обновите WebView2 и повторите попытку. + +--- + +### Windows: общие проблемы с производительностью + +Если вы испытываете низкую производительность, проблемы с доступом к файлам или проблемы с terminal в Windows, попробуйте использовать [WSL (подсистема Windows для Linux)](/docs/windows-wsl). WSL предоставляет среду Linux, которая более эффективно работает с функциями opencode. + +--- + +### Уведомления не отображаются + +opencode Desktop отображает системные уведомления только в следующих случаях: + +- уведомления для opencode включены в настройках вашей ОС, и +- окно приложения не в фокусе. + +--- + +### Сбросить хранилище настольных приложений (последнее средство) + +Если приложение не запускается и вы не можете очистить настройки из пользовательского интерфейса, сбросьте сохраненное состояние настольного приложения. + +1. Закройте рабочий стол opencode. +2. Найдите и удалите эти файлы (они находятся в каталоге данных приложения opencode Desktop): + +- `opencode.settings.dat` (URL-адрес сервера по умолчанию для рабочего стола) +- `opencode.global.dat` и `opencode.workspace.*.dat` (состояние пользовательского интерфейса, например, недавние серверы/проекты) + +Чтобы быстро найти каталог: + +- **macOS**: Finder -> `Cmd+Shift+G` -> `~/Library/Application Support` (затем найдите имена файлов, указанные выше) +- **Linux**: найдите в `~/.local/share` имена файлов, указанные выше. +- **Windows**: нажмите `WIN+R` -> `%APPDATA%` (затем найдите имена файлов, указанные выше). + +--- + +## Получение помощи + +Если у вас возникли проблемы с opencode: + +1. **Сообщайте о проблемах на GitHub** + + Лучший способ сообщить об ошибках или запросить новые функции — через наш репозиторий GitHub: + + [**github.com/anomalyco/opencode/issues**](https://github.com/anomalyco/opencode/issues) + + Прежде чем создавать новую проблему, выполните поиск по существующим проблемам, чтобы узнать, не сообщалось ли уже о вашей проблеме. + +2. **Присоединяйтесь к нашему Discord** + + Для получения помощи в режиме реального времени и обсуждения в сообществе присоединяйтесь к нашему серверу Discord: + + [**opencode.ai/discord**](https://opencode.ai/discord) + +--- + +## Общие проблемы + +Вот некоторые распространенные проблемы и способы их решения. + +--- + +### opencode не запускается + +1. Проверьте журналы на наличие сообщений об ошибках +2. Попробуйте запустить `--print-logs`, чтобы увидеть вывод в terminal. +3. Убедитесь, что у вас установлена ​​последняя версия `opencode upgrade`. + +--- + +### Проблемы аутентификации + +1. Попробуйте выполнить повторную аутентификацию с помощью команды `/connect` в TUI. +2. Убедитесь, что ваши ключи API действительны +3. Убедитесь, что ваша сеть разрешает подключения к API провайдера. + +--- + +### Модель недоступна + +1. Убедитесь, что вы прошли аутентификацию у провайдера +2. Проверьте правильность названия модели в вашей конфигурации. +3. Для некоторых моделей может потребоваться специальный доступ или подписка. + +Если вы столкнулись с `ProviderModelNotFoundError`, вы, скорее всего, ошибаетесь. +ссылка на модель где-то. +На модели следует ссылаться следующим образом: `/`. + +Примеры: + +- `openai/gpt-4.1` +- `openrouter/google/gemini-2.5-flash` +- `opencode/kimi-k2` + +Чтобы выяснить, к каким моделям у вас есть доступ, запустите `opencode models`. + +--- + +### ProviderInitError + +Если вы столкнулись с ошибкой ProviderInitError, скорее всего, у вас неверная или поврежденная конфигурация. + +Чтобы решить эту проблему: + +1. Сначала убедитесь, что ваш провайдер настроен правильно, следуя [руководству провайдеров](/docs/providers) +2. Если проблема не устранена, попробуйте очистить сохраненную конфигурацию: + + ```bash + rm -rf ~/.local/share/opencode + ``` + + В Windows нажмите `WIN+R` и удалите: `%USERPROFILE%\.local\share\opencode`. + +3. Повторно выполните аутентификацию у своего провайдера, используя команду `/connect` в TUI. + +--- + +### AI_APICallError и проблемы с пакетом провайдера + +Если вы столкнулись с ошибками вызова API, это может быть связано с устаревшими пакетами провайдера. opencode динамически устанавливает пакеты провайдеров (OpenAI, Anthropic, Google и т. д.) по мере необходимости и кэширует их локально. + +Чтобы решить проблемы с пакетом поставщика: + +1. Очистите кеш пакетов провайдера: + + ```bash + rm -rf ~/.cache/opencode + ``` + + В Windows нажмите `WIN+R` и удалите: `%USERPROFILE%\.cache\opencode`. + +2. Перезапустите opencode, чтобы переустановить последние пакеты поставщиков. + +Это заставит opencode загружать самые последние версии пакетов провайдеров, что часто решает проблемы совместимости с параметрами модели и изменениями API. + +--- + +### Копирование/вставка не работает в Linux + +Для работы функций копирования/вставки пользователям Linux необходимо установить одну из следующих утилит буфера обмена: + +**Для систем X11:** + +```bash +apt install -y xclip +# or +apt install -y xsel +``` + +**Для систем Wayland:** + +```bash +apt install -y wl-clipboard +``` + +**Для headless-сред:** + +```bash +apt install -y xvfb +# and run: +Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & +export DISPLAY=:99.0 +``` + +opencode определит, используете ли вы Wayland и предпочитаете `wl-clipboard`, в противном случае он попытается найти инструменты буфера обмена в порядке: `xclip` и `xsel`. diff --git a/packages/web/src/content/docs/ru/tui.mdx b/packages/web/src/content/docs/ru/tui.mdx new file mode 100644 index 0000000000000000000000000000000000000000..19d44539feef86be3e24e5e3f547c926e88bddd5 --- /dev/null +++ b/packages/web/src/content/docs/ru/tui.mdx @@ -0,0 +1,429 @@ +--- +title: TUI +description: Использование TUI opencode. +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" + +opencode предоставляет интерактивный terminal интерфейс или TUI для работы над вашими проектами с помощью LLM. + +Запуск opencode запускает TUI для текущего каталога. + +```bash +opencode +``` + +Или вы можете запустить его для определенного рабочего каталога. + +```bash +opencode /path/to/project +``` + +Как только вы окажетесь в TUI, вы можете запросить его с помощью сообщения. + +```text +Give me a quick summary of the codebase. +``` + +--- + +## Ссылки на файлы + +Вы можете ссылаться на файлы в своих сообщениях, используя `@`. Это выполняет нечеткий поиск файлов в текущем рабочем каталоге. + +:::tip +Вы также можете использовать `@` для ссылки на файлы в своих сообщениях. +::: + +```text "@packages/functions/src/api/index.ts" +How is auth handled in @packages/functions/src/api/index.ts? +``` + +Содержимое файла добавляется в беседу автоматически. + +--- + +## Bash-команды + +Начните сообщение с `!`, чтобы запустить shell-команду. + +```bash frame="none" +!ls -la +``` + +Вывод команды добавляется в диалог как результат работы инструмента. + +--- + +## Команды + +При использовании opencode TUI вы можете ввести `/`, а затем имя команды, чтобы быстро выполнить действия. Например: + +```bash frame="none" +/help +``` + +Большинство команд также имеют привязку клавиш с использованием `ctrl+x` в качестве ведущей клавиши, где `ctrl+x` — это ведущая клавиша по умолчанию. [Подробнее ](/docs/keybinds). + +Вот все доступные слэш-команды: + +--- + +### connect + +Добавьте провайдера в opencode. Позволяет выбирать из доступных поставщиков и добавлять их ключи API. + +```bash frame="none" +/connect +``` + +--- + +### compact + +Сжать текущий сеанс. _Псевдоним_: `/summarize` + +```bash frame="none" +/compact +``` + +**Привязка клавиш:** `ctrl+x c` + +--- + +### details + +Переключить детали выполнения инструмента. + +```bash frame="none" +/details +``` + +**Привязка клавиш:** `ctrl+x d` + +--- + +### editor + +Открыть внешний редактор для составления сообщений. Использует редактор, установленный в переменной среды `EDITOR`. [Подробнее ](#editor-setup). + +```bash frame="none" +/editor +``` + +**Привязка клавиш:** `ctrl+x e` + +--- + +### exit + +Выйдите из opencode. _Псевдонимы_: `/quit`, `/q` + +```bash frame="none" +/exit +``` + +**Привязка клавиш:** `ctrl+x q` + +--- + +### export + +Экспортируйте текущий разговор в Markdown и откройте его в редакторе по умолчанию. Использует редактор, установленный в переменной среды `EDITOR`. [Подробнее ](#editor-setup). + +```bash frame="none" +/export +``` + +**Привязка клавиш:** `ctrl+x x` + +--- + +### help + +Показать диалоговое окно помощи. + +```bash frame="none" +/help +``` + +**Привязка клавиш:** `ctrl+x h` + +--- + +### init + +Создайте или обновите файл `AGENTS.md`. [Подробнее ](/docs/rules). + +```bash frame="none" +/init +``` + +**Привязка клавиш:** `ctrl+x i` + +--- + +### models + +Перечислите доступные модели. + +```bash frame="none" +/models +``` + +**Привязка клавиш:** `ctrl+x m` + +--- + +### new + +Начать новый сеанс. _Псевдоним_: `/clear` + +```bash frame="none" +/new +``` + +**Привязка клавиш:** `ctrl+x n` + +--- + +### redo + +Повторить ранее отмененное сообщение. Доступно только после использования `/undo`. + +:::tip +Любые изменения файлов также будут восстановлены. +::: + +Внутри это использует Git для управления изменениями файлов. Итак, ваш проект ** должен +быть репозиторием Git**. + +```bash frame="none" +/redo +``` + +**Привязка клавиш:** `ctrl+x r` + +--- + +### sessions + +Составляйте список и переключайтесь между сеансами. _Псевдонимы_: `/resume`, `/continue` + +```bash frame="none" +/sessions +``` + +**Привязка клавиш:** `ctrl+x l` + +--- + +### share + +Поделиться текущим сеансом. [Подробнее](/docs/share). + +```bash frame="none" +/share +``` + +**Привязка клавиш:** `ctrl+x s` + +--- + +### theme + +Список доступных тем. + +```bash frame="none" +/theme +``` + +**Привязка клавиш:** `ctrl+x t` + +--- + +### thinking + +Переключить видимость блоков мышления/рассуждения в разговоре. Если этот параметр включен, вы можете увидеть процесс рассуждения модели для моделей, поддерживающих расширенное мышление. + +:::note +Эта команда только контролирует, будут ли **отображаться** блоки мышления, но не включает и не отключает возможности модели по рассуждению. Чтобы переключить фактические возможности рассуждения, используйте `ctrl+t` для циклического переключения вариантов модели. +::: + +```bash frame="none" +/thinking +``` + +--- + +### undo + +Отменить последнее сообщение в разговоре. Удаляет самое последнее сообщение пользователя, все последующие ответы и любые изменения файлов. + +:::tip +Любые внесенные изменения в файле также будут отменены. +::: + +Внутри это использует Git для управления изменениями файлов. Итак, ваш проект ** должен +быть репозиторием Git**. + +```bash frame="none" +/undo +``` + +**Привязка клавиш:** `ctrl+x u` + +--- + +### unshare + +Отменить общий доступ к текущему сеансу. [Подробнее](/docs/share#un-sharing). + +```bash frame="none" +/unshare +``` + +--- + +## Настройка редактора + +Команды `/editor` и `/export` используют редактор, указанный в переменной среды `EDITOR`. + + + + ```bash + # Example for nano or vim + export EDITOR=nano + export EDITOR=vim + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + export EDITOR="code --wait" + ``` + + Чтобы сделать его постоянным, добавьте это в свой профиль shell; + `~/.bashrc`, `~/.zshrc` и т. д. + + + + + ```bash + set EDITOR=notepad + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + set EDITOR=code --wait + ``` + + Чтобы сделать его постоянным, используйте **Свойства системы** > **Среда + Переменные**. + + + + + ```powershell + $env:EDITOR = "notepad" + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + $env:EDITOR = "code --wait" + ``` + + Чтобы сделать его постоянным, добавьте его в свой профиль PowerShell. + + + + +Популярные варианты редактора включают в себя: + +- `code` — VS Code +- `cursor` — Cursor +- `windsurf` - Windsurf +- `nvim` - Редактор Neovim +- `vim` — редактор Vim +- `nano` — Нано-редактор +- `notepad` — Блокнот Windows +- `subl` — Sublime Text + +:::note +Некоторые редакторы, такие как VS Code, необходимо запускать с флагом `--wait`. +::: + +Некоторым редакторам для работы в режиме блокировки необходимы CLI-аргументы. Флаг `--wait` блокирует процесс редактора до его закрытия. + +--- + +## Настройка + +Вы можете настроить поведение TUI через `tui.json` (или `tui.jsonc`). + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "opencode", + "leader_timeout": 2000, + "keybinds": { + "leader": "ctrl+x", + "command_list": "ctrl+p" + }, + "scroll_speed": 3, + "scroll_acceleration": { + "enabled": false + }, + "diff_style": "auto", + "mouse": true, + "attention": { + "enabled": true, + "notifications": true, + "sound": true, + "volume": 0.4, + "sound_pack": "opencode.default", + "sounds": { + "error": "./sounds/error.mp3" + } + } +} +``` + +Это отдельный файл от `opencode.json`, который настраивает поведение сервера/выполнения. + +`keybinds` объединяется со встроенными значениями по умолчанию, поэтому достаточно настроить только те сочетания клавиш, которые вы хотите изменить. + +### Параметры + +- `theme` — Устанавливает тему пользовательского интерфейса. [Подробнее](/docs/themes). +- `keybinds` — Настраивает сочетания клавиш. [Подробнее](/docs/keybinds). +- `leader_timeout` — Управляет тем, как долго OpenCode ждёт после нажатия leader key. По умолчанию `2000`. +- `scroll_acceleration.enabled` — включите ускорение прокрутки в стиле macOS для плавной и естественной прокрутки. Если этот параметр включен, скорость прокрутки увеличивается при быстрой прокрутке и остается точной при более медленных движениях. **Этот параметр имеет приоритет над `scroll_speed` и переопределяет его, если он включен.** +- `scroll_speed` — контролирует скорость прокрутки TUI при использовании команд прокрутки (минимум: `0.001`, поддерживает десятичные значения). По умолчанию `3`. **Примечание. Это игнорируется, если для `scroll_acceleration.enabled` установлено значение `true`.** +- `diff_style` — Управляет отображением различий. `"auto"` адаптируется к ширине терминала, `"stacked"` всегда показывает одноколоночный макет. +- `mouse` — Включает или отключает захват мыши в TUI (по умолчанию `true`). Если отключено, сохраняется нативное поведение терминала для выделения мышью и прокрутки. +- `attention` — Настраивает уведомления рабочего стола и звуки TUI. По умолчанию отключено. + +Используйте `OPENCODE_TUI_CONFIG` для загрузки пользовательского пути конфигурации TUI. + +### Attention + +Attention позволяет TUI уведомлять вас, когда OpenCode ждёт ответа, требует подтверждения разрешения, сообщает об ошибке сеанса или завершает сеанс. Включите это с помощью `attention.enabled`; встроенные события воспроизводят звук при срабатывании. Уведомления рабочего стола отправляются только тогда, когда окно терминала не в фокусе, и не используются для событий subagent. + +- `enabled` — Включает все уведомления и звуки Attention. По умолчанию `false`. +- `notifications` — Когда Attention включён, разрешает TUI отправлять уведомления рабочего стола через терминал. По умолчанию `true`. +- `sound` — Когда Attention включён, разрешает воспроизводить звуковые оповещения. По умолчанию `true`. +- `volume` — Громкость звуковых оповещений по умолчанию от `0` до `1`. По умолчанию `0.4`. +- `sound_pack` — ID sound pack для использования. По умолчанию `opencode.default`. +- `sounds` — Задаёт пользовательские звуковые файлы для `default`, `question`, `permission`, `error`, `done` или `subagent_done`. Пути могут быть абсолютными, `file://` URL или относительными к `tui.json`. + +--- + +## Кастомизация + +Вы можете настроить различные аспекты представления TUI, используя палитру команд (`ctrl+x h` или `/help`). Эти настройки сохраняются после перезапуска. + +--- + +#### Отображение имени пользователя + +Включите, будет ли ваше имя пользователя отображаться в сообщениях чата. Доступ к этому через: + +- Палитра команд: поиск «имя пользователя» или «скрыть имя пользователя». +- Настройка сохраняется автоматически и будет запоминаться во время сеансов TUI. diff --git a/packages/web/src/content/docs/ru/web.mdx b/packages/web/src/content/docs/ru/web.mdx new file mode 100644 index 0000000000000000000000000000000000000000..53122749f2bd9bf3ade2766e3062961fddc1bac6 --- /dev/null +++ b/packages/web/src/content/docs/ru/web.mdx @@ -0,0 +1,142 @@ +--- +title: Интернет +description: Использование opencode в вашем браузере. +--- + +opencode может работать как веб-приложение в вашем браузере, обеспечивая такой же мощный опыт кодирования AI без необходимости использования терминала. + +![opencode Web — новый сеанс](../../../assets/web/web-homepage-new-session.png) + +## Начало работы + +Запустите веб-интерфейс, выполнив: + +```bash +opencode web +``` + +Это запустит локальный сервер `127.0.0.1` со случайным доступным портом и автоматически откроет opencode в браузере по умолчанию. + +:::caution +Если `OPENCODE_SERVER_PASSWORD` не установлен, сервер будет незащищен. Это подходит для локального использования, но его следует настроить для доступа к сети. +::: + +:::tip[Пользователи Windows] +Для получения наилучших результатов запустите `opencode web` из [WSL](/docs/windows-wsl), а не из PowerShell. Это обеспечивает правильный доступ к файловой системе и интеграцию терминала. +::: + +--- + +## Конфигурация + +Вы можете настроить веб-сервер с помощью CLI-флагов или в файле [config file](/docs/config). + +### Порт + +По умолчанию opencode выбирает доступный порт. Вы можете указать порт: + +```bash +opencode web --port 4096 +``` + +### Имя хоста + +По умолчанию сервер привязывается к `127.0.0.1` (только локальный хост). Чтобы сделать opencode доступным в вашей сети: + +```bash +opencode web --hostname 0.0.0.0 +``` + +При использовании `0.0.0.0` opencode будет отображать как локальные, так и сетевые адреса: + +``` + Local access: http://localhost:4096 + Network access: http://192.168.1.100:4096 +``` + +### Обнаружение mDNS + +Включите mDNS, чтобы ваш сервер был доступен для обнаружения в локальной сети: + +```bash +opencode web --mdns +``` + +Это автоматически устанавливает имя хоста `0.0.0.0` и объявляет сервер как `opencode.local`. + +Вы можете настроить доменное имя mDNS для запуска нескольких экземпляров в одной сети: + +```bash +opencode web --mdns --mdns-domain myproject.local +``` + +### CORS + +Чтобы разрешить дополнительные домены для CORS (полезно для пользовательских интерфейсов): + +```bash +opencode web --cors https://example.com +``` + +### Аутентификация + +Чтобы защитить доступ, установите пароль, используя переменную среды `OPENCODE_SERVER_PASSWORD`: + +```bash +OPENCODE_SERVER_PASSWORD=secret opencode web +``` + +Имя пользователя по умолчанию — `opencode`, но его можно изменить с помощью `OPENCODE_SERVER_USERNAME`. + +--- + +## Использование веб-интерфейса + +После запуска веб-интерфейс предоставляет доступ к вашим сеансам opencode. + +### Сессии + +Просматривайте свои сеансы и управляйте ими с главной страницы. Вы можете видеть активные сеансы и начинать новые. + +![opencode Web — активный сеанс](../../../assets/web/web-homepage-active-session.png) + +### Статус сервера + +Нажмите «Просмотреть серверы», чтобы просмотреть подключенные серверы и их статус. + +![opencode Web — см. Серверы](../../../assets/web/web-homepage-see-servers.png) + +--- + +## Подключение терминала + +Вы можете подключить TUI терминала к работающему веб-серверу: + +```bash +# Start the web server +opencode web --port 4096 + +# In another terminal, attach the TUI +opencode attach http://localhost:4096 +``` + +Это позволяет вам одновременно использовать веб-интерфейс и терминал, используя одни и те же сеансы и состояние. + +--- + +## Конфигурационный файл + +Вы также можете настроить параметры сервера в файле конфигурации `opencode.json`: + +```json +{ + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true, + "cors": ["https://example.com"] + } +} +``` + +CLI-флаги имеют приоритет над настройками файла конфигурации. diff --git a/packages/web/src/content/docs/ru/windows-wsl.mdx b/packages/web/src/content/docs/ru/windows-wsl.mdx new file mode 100644 index 0000000000000000000000000000000000000000..7ca28449b5f0c5162de28dbe59b88c30620739b8 --- /dev/null +++ b/packages/web/src/content/docs/ru/windows-wsl.mdx @@ -0,0 +1,113 @@ +--- +title: Windows (WSL) +description: Запускайте opencode в Windows через WSL. +--- + +import { Steps } from "@astrojs/starlight/components" + +opencode можно запускать напрямую в Windows, но для лучшего опыта мы рекомендуем [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install). WSL дает Linux-среду, которая отлично работает с возможностями opencode. + +:::tip[Почему WSL?] +WSL дает более высокую производительность файловой системы, полноценную поддержку терминала и совместимость с инструментами разработки, на которые опирается opencode. +::: + +--- + +## Настройка + + + +1. **Установите WSL** + + Если вы еще не сделали этого, установите WSL по [официальному руководству Microsoft](https://learn.microsoft.com/en-us/windows/wsl/install). + +2. **Установите opencode в WSL** + + После настройки WSL откройте терминал WSL и установите opencode одним из [способов установки](/docs/). + + ```bash + curl -fsSL https://opencode.ai/install | bash + ``` + +3. **Запускайте opencode из WSL** + + Перейдите в каталог проекта (к файлам Windows можно обращаться через `/mnt/c/`, `/mnt/d/` и т.д.) и запустите opencode. + + ```bash + cd /mnt/c/Users/YourName/project + opencode + ``` + + + +--- + +## Десктопное приложение + сервер в WSL + +Если вы предпочитаете opencode Desktop, но хотите запускать сервер в WSL: + +1. **Запустите сервер в WSL** с параметром `--hostname 0.0.0.0`, чтобы разрешить внешние подключения: + + ```bash + opencode serve --hostname 0.0.0.0 --port 4096 + ``` + +2. **Подключите десктопное приложение** к `http://localhost:4096` + +:::note +Если в вашей конфигурации `localhost` не работает, используйте IP-адрес WSL (выполните в WSL: `hostname -I`) и подключайтесь по `http://:4096`. +::: + +:::caution +При использовании `--hostname 0.0.0.0` задайте `OPENCODE_SERVER_PASSWORD`, чтобы защитить сервер. + +```bash +OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 +``` + +::: + +--- + +## Веб-клиент + WSL + +Для лучшего веб-опыта в Windows: + +1. **Запускайте `opencode web` в терминале WSL**, а не в PowerShell: + + ```bash + opencode web --hostname 0.0.0.0 + ``` + +2. **Открывайте в браузере Windows** адрес `http://localhost:` (opencode выведет URL) + +Запуск `opencode web` из WSL обеспечивает корректный доступ к файловой системе и интеграцию с терминалом, при этом интерфейс остается доступным из браузера Windows. + +--- + +## Доступ к файлам Windows + +WSL может получать доступ ко всем вашим файлам Windows через каталог `/mnt/`: + +- `C:` drive → `/mnt/c/` +- `D:` drive → `/mnt/d/` +- И так далее + +Пример: + +```bash +cd /mnt/c/Users/YourName/Documents/project +opencode +``` + +:::tip +Для максимально плавной работы стоит клонировать или скопировать репозиторий в файловую систему WSL (например, в `~/code/`) и запускать opencode оттуда. +::: + +--- + +## Советы + +- Даже для проектов на дисках Windows запускайте opencode в WSL, чтобы получить более плавный доступ к файлам +- Используйте opencode вместе с [расширением WSL для VS Code](https://code.visualstudio.com/docs/remote/wsl) для единого рабочего процесса +- Конфигурация и сессии opencode хранятся в среде WSL по пути `~/.local/share/opencode/` diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx new file mode 100644 index 0000000000000000000000000000000000000000..e00c26e5096419b06c472794efdcde48f63397f3 --- /dev/null +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -0,0 +1,369 @@ +--- +title: Zen +description: Подобранный список моделей, предоставленный OpenCode. +--- + +import config from "../../../../config.mjs" +export const console = config.console +export const email = `mailto:${config.email}` + +OpenCode Zen — это список протестированных и проверенных моделей, предоставленный командой OpenCode. + +Zen работает как любой другой провайдер в OpenCode. Вы входите в OpenCode Zen и получаете +свой ключ API. Это **полностью необязательно**, и вам не нужно использовать его, +чтобы пользоваться OpenCode. + +--- + +## Предыстория + +Существует огромное количество моделей, но только немногие из +них хорошо работают как агенты для программирования. Кроме того, большинство провайдеров +настроены очень по-разному, поэтому производительность и качество могут сильно отличаться. + +:::tip +Мы протестировали выбранную группу моделей и провайдеров, которые хорошо работают с OpenCode. +::: + +Поэтому, если вы используете модель через что-то вроде OpenRouter, вы никогда не можете +быть уверены, что получаете лучшую версию нужной вам модели. + +Чтобы это исправить, мы сделали несколько вещей: + +1. Мы протестировали выбранную группу моделей и обсудили с их командами, как + лучше всего их запускать. +2. Затем мы поработали с несколькими провайдерами, чтобы убедиться, что эти модели + отдаются корректно. +3. Наконец, мы сравнили комбинации модель/провайдер и составили + список, который готовы рекомендовать. + +OpenCode Zen — это AI-шлюз, который дает вам доступ к этим моделям. + +--- + +## Как это работает + +OpenCode Zen работает как любой другой провайдер в OpenCode. + +1. Вы входите в **OpenCode Zen**, добавляете платежные + данные и копируете свой ключ API. +2. Вы запускаете команду `/connect` в TUI, выбираете OpenCode Zen и вставляете свой ключ API. +3. Запустите `/models` в TUI, чтобы увидеть список моделей, которые мы рекомендуем. + +Плата взимается за каждый запрос, и вы можете пополнять баланс своего аккаунта. + +--- + +## Конечные точки + +Вы также можете получить доступ к нашим моделям через следующие конечные точки API. + +| Модель | Идентификатор модели | Конечная точка | Пакет AI SDK | +| ------------------------------- | ------------------------------- | --------------------------------------------------------- | --------------------------- | +| GPT 6 Astra | gpt-6-astra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Fable 5.1 | claude-fable-5-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 5 | claude-sonnet-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.8 Flash | gemini-3.8-flash | `https://opencode.ai/zen/v1/models/gemini-3.8-flash` | `@ai-sdk/google` | +| Gemini 3.7 Flash | gemini-3.7-flash | `https://opencode.ai/zen/v1/models/gemini-3.7-flash` | `@ai-sdk/google` | +| Gemini 3.6 Flash | gemini-3.6-flash | `https://opencode.ai/zen/v1/models/gemini-3.6-flash` | `@ai-sdk/google` | +| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | +| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.3 | muse-spark-1.3 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.2 | muse-spark-1.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Vision Exp | deepseek-v4-flash-vision-exp | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.3 Flash | glm-5.3-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.3 | glm-5.3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling 3.0 Flash Fin Free | ling-3.0-flash-fin-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3.5 Lightning Free | nemotron-3.5-lightning-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Muse Spark 1.3 Contributor Free | muse-spark-1.3-contributor-free | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | + +[идентификатор модели](/docs/config/#models) в вашей конфигурации OpenCode +использует формат `opencode/`. Например, для GPT 5.5 вам нужно +использовать `opencode/gpt-5.5` в своей конфигурации. + +--- + +### Модели + +Вы можете получить полный список доступных моделей и их метаданных по адресу: + +``` +https://opencode.ai/zen/v1/models +``` + +--- + +## Цены + +Мы поддерживаем оплату по мере использования. Ниже указаны цены **за 1M токенов**. + +| Модель | Вход | Выход | Cached Read | Cached Write | +| --------------------------------- | ------ | ------- | ----------- | ------------ | +| Big Pickle | Free | Free | Free | - | +| MiMo-V2.5 Free | Free | Free | Free | - | +| Ling 3.0 Flash Fin Free | Free | Free | Free | - | +| Nemotron 3 Ultra Free | Free | Free | Free | - | +| Nemotron 3.5 Lightning Free | Free | Free | Free | - | +| Muse Spark 1.3 Contributor Free | Free | Free | Free | - | +| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | - | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - | +| GLM 5.3 Flash | $0.15 | $0.50 | $0.03 | - | +| GLM 5.3 | $1.40 | $4.40 | $0.26 | - | +| GLM 5.2 | $1.40 | $4.40 | $0.26 | - | +| GLM 5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM 5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.7 Plus | $0.40 | $1.60 | $0.04 | $0.50 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | +| DeepSeek V4 Flash Vision Exp | $0.14 | $0.28 | $0.028 | - | +| Claude Fable 5.1 | $10.00 | $50.00 | $0.25 | $12.50 | +| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Sonnet 5 | $2.00 | $10.00 | $0.20 | $2.50 | +| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | +| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 | +| Gemini 3.8 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.7 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.6 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.5 Flash | $1.50 | $9.00 | $0.15 | - | +| Gemini 3.5 Flash Lite | $0.30 | $2.50 | $0.03 | - | +| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | +| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | +| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | +| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | +| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | +| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - | +| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - | +| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | +| Muse Spark 1.3 | $1.25 | $4.25 | $0.15 | - | +| Muse Spark 1.2 | $1.25 | $4.25 | $0.15 | - | +| GPT 6 Astra (≤ 272K tokens) | $10.00 | $50.00 | $1.00 | $12.50 | +| GPT 6 Astra (> 272K tokens) | $20.00 | $75.00 | $2.00 | $25.00 | +| GPT 5.6 Sol (≤ 272K tokens) | $2.00 | $10.00 | $0.20 | $2.50 | +| GPT 5.6 Sol (> 272K tokens) | $4.00 | $15.00 | $0.40 | $5.00 | +| GPT 5.6 Terra (≤ 272K tokens) | $2.00 | $12.00 | $0.20 | $2.50 | +| GPT 5.6 Terra (> 272K tokens) | $4.00 | $18.00 | $0.40 | $5.00 | +| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | +| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | +| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | +| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | +| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | +| GPT 5.4 (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | - | +| GPT 5.4 (> 272K tokens) | $5.00 | $22.50 | $0.50 | - | +| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - | +| GPT 5.4 Mini | $0.75 | $4.50 | $0.075 | - | +| GPT 5.4 Nano | $0.20 | $1.25 | $0.02 | - | +| GPT 5.3 Codex Spark | $1.75 | $14.00 | $0.175 | - | +| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - | +| GPT 5.2 | $1.75 | $14.00 | $0.175 | - | +| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - | +| GPT 5.1 | $1.07 | $8.50 | $0.107 | - | +| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - | +| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - | +| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | +| GPT 5 | $1.07 | $8.50 | $0.107 | - | +| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | + +**GPT 5.6 Sol:** Указанные цены включают скидку 50% до 18 сентября 2026 года. + +**DeepSeek V4 Flash Vision Exp:** Изображения преобразуются в токены с учётом их размеров и оплачиваются как входные токены вместе с текстовыми токенами. [Подробнее](https://api-docs.deepseek.com/quick_start/pricing/). + +В истории использования могут появляться [недорогие модели](/docs/config/#models), такие как Haiku, Nano или Flash. OpenCode использует эти модели для создания заголовков сессий. + +:::note +Комиссии по кредитным картам передаются по себестоимости (4.4% + $0.30 за транзакцию); мы ничего не начисляем сверх этого. +::: + +Бесплатные модели: + +- MiMo-V2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Ling 3.0 Flash Fin Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Nemotron 3 Ultra Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Nemotron 3.5 Lightning Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Muse Spark 1.3 Contributor Free доступна в OpenCode в течение ограниченного времени. Команда использует этот период для сбора отзывов и улучшения модели. + +Свяжитесь с нами, если у вас есть вопросы. + +--- + +### Автопополнение + +Если ваш баланс опустится ниже $5, Zen автоматически пополнит его на $20. + +Вы можете изменить сумму автопополнения. Также можно полностью отключить автопополнение. + +--- + +### Ежемесячные лимиты + +Вы также можете установить ежемесячный лимит использования для всего рабочего пространства и для каждого +участника вашей команды. + +Например, предположим, что вы установили ежемесячный лимит использования в $20. Zen не будет +использовать больше $20 в месяц. Но если у вас включено автопополнение, Zen может +в итоге списать с вас больше $20, если ваш баланс опустится ниже $5. + +--- + +### Устаревшие модели + +| Модель | Дата устаревания | +| ------------------ | ----------------- | +| GPT 5.2 Codex | July 23, 2026 | +| GPT 5.1 Codex | July 23, 2026 | +| GPT 5.1 Codex Max | July 23, 2026 | +| GPT 5.1 Codex Mini | July 23, 2026 | +| GPT 5 Codex | July 23, 2026 | +| Claude Opus 4.1 | August 5, 2026 | +| Claude Sonnet 4 | June 15, 2026 | +| Claude Haiku 3.5 | February 16, 2026 | +| Gemini 3 Pro | March 9, 2026 | +| MiniMax M2.5 | August 5, 2026 | +| MiniMax M2.1 | March 15, 2026 | +| GLM 5 | May 14, 2026 | +| GLM 4.7 | March 15, 2026 | +| GLM 4.6 | March 15, 2026 | +| Kimi K2.5 | August 5, 2026 | +| Kimi K2 Thinking | March 6, 2026 | +| Kimi K2 | March 6, 2026 | +| Qwen3 Coder 480B | February 6, 2026 | + +--- + +## Конфиденциальность + +Все наши модели размещены в US. Наши провайдеры придерживаются политики нулевого хранения и не используют ваши данные для обучения моделей, за следующими исключениями: + +- Big Pickle: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- MiMo-V2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- Ling 3.0 Flash Fin Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- Nemotron 3 Ultra Free (бесплатные эндпоинты NVIDIA): только для пробного использования — не отправляйте персональные или конфиденциальные данные. Использование логируется в целях безопасности и для улучшения продуктов и сервисов NVIDIA. Логируемые данные сессии, используемые в целях улучшения, не связаны с вашей личностью или каким-либо постоянным идентификатором. Подробнее о наших практиках обработки данных см. в нашей [Политике конфиденциальности](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Взаимодействуя с этим эндпоинтом, вы соглашаетесь на сбор, запись и использование нами такой информации, а также с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). +- Nemotron 3.5 Lightning Free (бесплатные эндпоинты NVIDIA): только для пробного использования — не отправляйте персональные или конфиденциальные данные. Использование логируется в целях безопасности и для улучшения продуктов и сервисов NVIDIA. Логируемые данные сессии, используемые в целях улучшения, не связаны с вашей личностью или каким-либо постоянным идентификатором. Подробнее о наших практиках обработки данных см. в нашей [Политике конфиденциальности](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Взаимодействуя с этим эндпоинтом, вы соглашаетесь на сбор, запись и использование нами такой информации, а также с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). +- OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). +- Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). +- Muse Spark 1.3 Contributor Free: значительно сниженная стоимость токенов в обмен на разрешение использовать ваши запросы и ответы для обучения будущих моделей Meta. [Подробнее](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier). + +--- + +## Для команд + +Zen также отлично подходит для команд. Вы можете приглашать коллег, назначать роли, выбирать, +какие модели использует ваша команда, и многое другое. + +:::note +Рабочие пространства сейчас бесплатны для команд в рамках бета-версии. +::: + +Управление вашим рабочим пространством сейчас бесплатно для команд в рамках бета-версии. Скоро мы +поделимся более подробной информацией о ценах. + +--- + +### Роли + +Вы можете приглашать коллег в свое рабочее пространство и назначать роли: + +- **Admin**: управляет моделями, участниками, ключами API и выставлением счетов +- **Member**: управляет только своими ключами API + +Администраторы также могут устанавливать ежемесячные лимиты расходов для каждого участника, чтобы держать затраты под контролем. + +--- + +### Доступ к моделям + +Администраторы могут включать или отключать определенные модели для рабочего пространства. Запросы к отключенной модели будут возвращать ошибку. + +Это полезно в случаях, когда вы хотите отключить использование модели, которая +собирает данные. + +--- + +### Использование собственного ключа + +Вы можете использовать свои собственные ключи API OpenAI или Anthropic, сохраняя доступ к другим моделям в Zen. + +Когда вы используете собственные ключи, токены тарифицируются напрямую провайдером, а не Zen. + +Например, у вашей организации уже может быть ключ для OpenAI или Anthropic, +и вы хотите использовать его вместо того, который предоставляет Zen. + +--- + +## Цели + +Мы создали OpenCode Zen, чтобы: + +1. **Сравнить** лучшие комбинации модель/провайдер для агентов для программирования. +2. Иметь доступ к вариантам **наивысшего качества** и не снижать производительность, а также не маршрутизировать запросы к более дешевым провайдерам. +3. Передавать любые **снижения цен**, продавая по себестоимости; так что единственная наценка нужна для покрытия наших комиссий за обработку. +4. Обеспечить **отсутствие привязки**, позволяя вам использовать его с любым другим агентом для программирования. И всегда позволять вам использовать любого другого провайдера с OpenCode. diff --git a/packages/web/src/content/docs/zh-cn/acp.mdx b/packages/web/src/content/docs/zh-cn/acp.mdx new file mode 100644 index 0000000000000000000000000000000000000000..7d88084060c99491854f2f878367a668d0699473 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/acp.mdx @@ -0,0 +1,159 @@ +--- +title: ACP 支持 +description: 在任何兼容 ACP 的编辑器中使用 OpenCode。 +--- + +OpenCode 支持 [Agent Client Protocol](https://agentclientprotocol.com)(ACP),允许你直接在兼容的编辑器和 IDE 中使用它。 + +:::tip +有关支持 ACP 的编辑器和工具列表,请查看 [ACP 进展报告](https://zed.dev/blog/acp-progress-report#available-now)。 +::: + +ACP 是一个开放协议,用于标准化代码编辑器与 AI 编码代理之间的通信。 + +--- + +## 配置 + +要通过 ACP 使用 OpenCode,请在编辑器中配置运行 `opencode acp` 命令。 + +该命令会将 OpenCode 作为兼容 ACP 的子进程启动,通过 stdio 上的 JSON-RPC 与编辑器进行通信。 + +以下是支持 ACP 的常用编辑器的配置示例。 + +--- + +### Zed + +在命令面板中运行 `zed: acp registry`,从 [Zed ACP 注册表](https://zed.dev/docs/ai/external-agents#registry)安装 OpenCode。 + +如果要改用自定义 OpenCode 可执行文件,请将其添加到 [Zed](https://zed.dev) 配置文件(`~/.config/zed/settings.json`)中: + +```json title="~/.config/zed/settings.json" +{ + "agent_servers": { + "OpenCode": { + "type": "custom", + "command": "opencode", + "args": ["acp"] + } + } +} +``` + +打开方式:在**命令面板**中执行 `agent: new thread` 操作。 + +你也可以通过编辑 `keymap.json` 来绑定键盘快捷键: + +```json title="keymap.json" +[ + { + "bindings": { + "cmd-alt-o": [ + "agent::NewExternalAgentThread", + { + "agent": { + "custom": { + "name": "OpenCode", + "command": { + "command": "opencode", + "args": ["acp"] + } + } + } + } + ] + } + } +] +``` + +--- + +### JetBrains IDEs + +根据[文档](https://www.jetbrains.com/help/ai-assistant/acp.html),将以下内容添加到你的 [JetBrains IDE](https://www.jetbrains.com/) 的 acp.json 中: + +```json title="acp.json" +{ + "agent_servers": { + "OpenCode": { + "command": "/absolute/path/bin/opencode", + "args": ["acp"] + } + } +} +``` + +打开方式:在 AI Chat 代理选择器中选择新的 'OpenCode' 代理。 + +--- + +### Avante.nvim + +添加到你的 [Avante.nvim](https://github.com/yetone/avante.nvim) 配置中: + +```lua +{ + acp_providers = { + ["opencode"] = { + command = "opencode", + args = { "acp" } + } + } +} +``` + +如果需要传递环境变量: + +```lua {6-8} +{ + acp_providers = { + ["opencode"] = { + command = "opencode", + args = { "acp" }, + env = { + OPENCODE_API_KEY = os.getenv("OPENCODE_API_KEY") + } + } + } +} +``` + +--- + +### CodeCompanion.nvim + +要在 [CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim) 中将 OpenCode 用作 ACP 代理,请将以下内容添加到你的 Neovim 配置中: + +```lua +require("codecompanion").setup({ + interactions = { + chat = { + adapter = { + name = "opencode", + model = "claude-sonnet-4", + }, + }, + }, +}) +``` + +此配置将 CodeCompanion 设置为使用 OpenCode 作为聊天的 ACP 代理。 + +如果需要传递环境变量(如 `OPENCODE_API_KEY`),请参阅 CodeCompanion.nvim 文档中的[配置适配器:环境变量](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key)了解详细信息。 + +## 支持 + +OpenCode 通过 ACP 使用时与在终端中使用的效果完全一致。所有功能均受支持: + +:::note +部分内置斜杠命令(如 `/undo` 和 `/redo`)目前暂不支持。 +::: + +- 内置工具(文件操作、终端命令等) +- 自定义工具和斜杠命令 +- 在 OpenCode 配置中配置的 MCP 服务器 +- 来自 `AGENTS.md` 的项目级规则 +- 自定义格式化工具和代码检查工具 +- 代理和权限系统 diff --git a/packages/web/src/content/docs/zh-cn/agents.mdx b/packages/web/src/content/docs/zh-cn/agents.mdx new file mode 100644 index 0000000000000000000000000000000000000000..6f821ff7f8697c0599832de19962741edd372b35 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/agents.mdx @@ -0,0 +1,754 @@ +--- +title: 代理 +description: 配置和使用专门的代理。 +--- + +代理是专门的 AI 助手,可以针对特定任务和工作流程进行配置。它们允许您创建具有自定义提示词、模型和工具访问权限的专用工具。 + +:::tip +使用 Plan 代理来分析代码和审查建议,而不会进行任何代码更改。 +::: + +您可以在会话期间切换代理,或使用 `@` 提及来调用它们。 + +--- + +## 类型 + +OpenCode 中有两种类型的代理:主代理和子代理。 + +--- + +### 主代理 + +主代理是您直接交互的主要助手。您可以使用 **Tab** 键或配置的 `switch_agent` 快捷键来循环切换它们。这些代理处理您的主要对话。工具访问通过权限进行配置——例如,Build 启用了所有工具,而 Plan 则受到限制。 + +:::tip +您可以在会话期间使用 **Tab** 键在主代理之间切换。 +::: + +OpenCode 内置了两个主代理:**Build** 和 **Plan**。我们将在下面介绍它们。 + +--- + +### 子代理 + +子代理是主代理可以调用来执行特定任务的专业助手。您也可以通过在消息中 **@ 提及**它们来手动调用。 + +OpenCode 内置了三个子代理:**General**、**Explore** 和 **Scout**。我们将在下面介绍它们。 + +--- + +## 内置代理 + +OpenCode 内置了两个主代理和三个子代理。 + +--- + +### 使用 Build + +_模式_:`primary` + +Build 是启用了所有工具的**默认**主代理。这是用于需要完全访问文件操作和系统命令的开发工作的标准代理。 + +--- + +### 使用 Plan + +_模式_:`primary` + +一个专为规划和分析设计的受限代理。我们使用权限系统来为您提供更多控制权,并防止意外更改。 +默认情况下,以下所有项均设置为 `ask`: + +- `file edits`:所有写入、补丁和编辑 +- `bash`:所有 bash 命令 + +当您希望 LLM 分析代码、建议更改或创建计划,而不对代码库进行任何实际修改时,此代理非常有用。 + +--- + +### 使用 General + +_模式_:`subagent` + +一个用于研究复杂问题和执行多步骤任务的通用代理。拥有完整的工具访问权限(todo 除外),因此可以在需要时修改文件。可用于并行运行多个工作单元。 + +--- + +### 使用 Explore + +_模式_:`subagent` + +一个用于探索代码库的快速只读代理。无法修改文件。当您需要按模式快速查找文件、搜索代码中的关键字或回答有关代码库的问题时,请使用此代理。 + +--- + +### 使用 Scout + +_模式_:`subagent` + +一个用于外部文档和依赖研究的只读代理。当您需要将某个依赖仓库克隆到 OpenCode 的托管缓存中、检查库的源代码,或在不修改工作区的情况下将本地代码与 upstream 实现进行交叉对照时,请使用此代理。 + +--- + +### 使用 Compaction + +_模式_:`primary` + +隐藏的系统代理,将长上下文压缩为较小的摘要。它会在需要时自动运行,且无法在 UI 中选择。 + +--- + +### 使用 Title + +_模式_:`primary` + +隐藏的系统代理,用于生成简短的会话标题。它会自动运行,且无法在 UI 中选择。 + +--- + +### 使用 Summary + +_模式_:`primary` + +隐藏的系统代理,用于创建会话摘要。它会自动运行,且无法在 UI 中选择。 + +--- + +## 用法 + +1. 对于主代理,在会话期间使用 **Tab** 键循环切换。您也可以使用配置的 `switch_agent` 快捷键。 + +2. 子代理可以通过以下方式调用: + - 由主代理根据其描述**自动**调用以执行专门任务。 + - 通过在消息中 **@ 提及**子代理来手动调用。例如: + + ```txt frame="none" + @general help me search for this function + ``` + +3. **会话间导航**:当子代理创建自己的子会话时,您可以使用以下方式在父会话和所有子会话之间导航: + - **\+Right**(或配置的 `session_child_cycle` 快捷键)向前循环:父会话 → 子会话1 → 子会话2 → ... → 父会话 + - **\+Left**(或配置的 `session_child_cycle_reverse` 快捷键)向后循环:父会话 ← 子会话1 ← 子会话2 ← ... ← 父会话 + + 这使您可以在主对话和专门的子代理工作之间无缝切换。 + +--- + +## 配置 + +您可以自定义内置代理或通过配置创建自己的代理。代理可以通过两种方式进行配置: + +--- + +### JSON + +在 `opencode.json` 配置文件中配置代理: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "mode": "primary", + "model": "anthropic/claude-sonnet-4-20250514", + "prompt": "{file:./prompts/build.txt}", + "tools": { + "write": true, + "edit": true, + "bash": true + } + }, + "plan": { + "mode": "primary", + "model": "anthropic/claude-haiku-4-20250514", + "tools": { + "write": false, + "edit": false, + "bash": false + } + }, + "code-reviewer": { + "description": "Reviews code for best practices and potential issues", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-20250514", + "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", + "tools": { + "write": false, + "edit": false + } + } + } +} +``` + +--- + +### Markdown + +您还可以使用 Markdown 文件定义代理。将它们放在: + +- 全局:`~/.config/opencode/agents/` +- 项目级:`.opencode/agents/` + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Reviews code for quality and best practices +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +temperature: 0.1 +tools: + write: false + edit: false + bash: false +--- + +You are in code review mode. Focus on: + +- Code quality and best practices +- Potential bugs and edge cases +- Performance implications +- Security considerations + +Provide constructive feedback without making direct changes. +``` + +Markdown 文件名即为代理名称。例如,`review.md` 会创建一个名为 `review` 的代理。 + +--- + +## 选项 + +让我们详细了解这些配置选项。 + +--- + +### 描述 + +使用 `description` 选项提供代理的功能及使用场景的简要描述。 + +```json title="opencode.json" +{ + "agent": { + "review": { + "description": "Reviews code for best practices and potential issues" + } + } +} +``` + +这是一个**必需的**配置选项。 + +--- + +### 温度 + +使用 `temperature` 配置控制 LLM 响应的随机性和创造力。 + +较低的值使响应更加集中和确定,而较高的值则增加创造力和多样性。 + +```json title="opencode.json" +{ + "agent": { + "plan": { + "temperature": 0.1 + }, + "creative": { + "temperature": 0.8 + } + } +} +``` + +温度值通常范围为 0.0 到 1.0: + +- **0.0-0.2**:非常集中和确定性的响应,适合代码分析和规划 +- **0.3-0.5**:平衡的响应,兼顾一定创造力,适合一般开发任务 +- **0.6-1.0**:更有创造力和多样性的响应,适合头脑风暴和探索 + +```json title="opencode.json" +{ + "agent": { + "analyze": { + "temperature": 0.1, + "prompt": "{file:./prompts/analysis.txt}" + }, + "build": { + "temperature": 0.3 + }, + "brainstorm": { + "temperature": 0.7, + "prompt": "{file:./prompts/creative.txt}" + } + } +} +``` + +如果未指定温度,OpenCode 将使用模型特定的默认值;大多数模型通常为 0,Qwen 模型为 0.55。 + +--- + +### 最大步数 + +控制代理在被强制以纯文本响应之前可以执行的最大代理迭代次数。这允许希望控制成本的用户对代理操作设置限制。 + +如果未设置此选项,代理将持续迭代,直到模型选择停止或用户中断会话。 + +```json title="opencode.json" +{ + "agent": { + "quick-thinker": { + "description": "Fast reasoning with limited iterations", + "prompt": "You are a quick thinker. Solve problems with minimal steps.", + "steps": 5 + } + } +} +``` + +当达到限制时,代理会收到一个特殊的系统提示词,指示其回复工作摘要和建议的剩余任务。 + +:::caution +旧版 `maxSteps` 字段已弃用。请改用 `steps`。 +::: + +--- + +### 禁用 + +设置为 `true` 以禁用代理。 + +```json title="opencode.json" +{ + "agent": { + "review": { + "disable": true + } + } +} +``` + +--- + +### 提示词 + +使用 `prompt` 配置为代理指定自定义系统提示词文件。提示词文件应包含针对代理用途的具体指令。 + +```json title="opencode.json" +{ + "agent": { + "review": { + "prompt": "{file:./prompts/code-review.txt}" + } + } +} +``` + +此路径相对于配置文件所在位置。因此它同时适用于全局 OpenCode 配置和项目级配置。 + +--- + +### 模型 + +使用 `model` 配置为代理覆盖模型。适用于针对不同任务使用不同的优化模型。例如,用更快的模型进行规划,用更强大的模型进行实现。 + +:::tip +如果您不指定模型,主代理将使用[全局配置的模型](/docs/config#models),而子代理将使用调用它的主代理所使用的模型。 +::: + +```json title="opencode.json" +{ + "agent": { + "plan": { + "model": "anthropic/claude-haiku-4-20250514" + } + } +} +``` + +OpenCode 配置中的模型 ID 使用 `provider/model-id` 格式。例如,如果您使用 [OpenCode Zen](/docs/zen),则可以使用 `opencode/gpt-5.1-codex` 来表示 GPT 5.1 Codex。 + +--- + +### 工具 + +使用 `tools` 配置控制代理中可用的工具。您可以通过将特定工具设置为 `true` 或 `false` 来启用或禁用它们。 + +```json title="opencode.json" {3-6,9-12} +{ + "$schema": "https://opencode.ai/config.json", + "tools": { + "write": true, + "bash": true + }, + "agent": { + "plan": { + "tools": { + "write": false, + "bash": false + } + } + } +} +``` + +:::note +代理级配置会覆盖全局配置。 +::: + +您还可以使用通配符同时控制多个工具。例如,要禁用 MCP 服务器中的所有工具: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "readonly": { + "tools": { + "mymcp_*": false, + "write": false, + "edit": false + } + } + } +} +``` + +[了解更多关于工具的信息](/docs/tools)。 + +--- + +### 权限 + +您可以配置权限来管理代理可以执行的操作。目前,`edit`、`bash` 和 `webfetch` 工具的权限可以配置为: + +- `"ask"` — 运行工具前提示审批 +- `"allow"` — 允许所有操作,无需审批 +- `"deny"` — 禁用该工具 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny" + } +} +``` + +您可以按代理覆盖这些权限。 + +```json title="opencode.json" {3-5,8-10} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny" + }, + "agent": { + "build": { + "permission": { + "edit": "ask" + } + } + } +} +``` + +您还可以在 Markdown 代理中设置权限。 + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Code review without edits +mode: subagent +permission: + edit: deny + bash: + "*": ask + "git diff": allow + "git log*": allow + "grep *": allow + webfetch: deny +--- + +Only analyze code and suggest changes. +``` + +您可以为特定的 bash 命令设置权限。 + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "git push": "ask", + "grep *": "allow" + } + } + } + } +} +``` + +这可以使用 glob 模式。 + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "git *": "ask" + } + } + } + } +} +``` + +您还可以使用 `*` 通配符来管理所有命令的权限。 +由于最后匹配的规则优先,请将 `*` 通配符放在前面,将具体规则放在后面。 + +```json title="opencode.json" {8} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git status *": "allow" + } + } + } + } +} +``` + +[了解更多关于权限的信息](/docs/permissions)。 + +--- + +### 模式 + +使用 `mode` 配置控制代理的模式。`mode` 选项用于确定代理的使用方式。 + +```json title="opencode.json" +{ + "agent": { + "review": { + "mode": "subagent" + } + } +} +``` + +`mode` 选项可以设置为 `primary`、`subagent` 或 `all`。如果未指定 `mode`,则默认为 `all`。 + +--- + +### 隐藏 + +使用 `hidden: true` 将子代理从 `@` 自动补全菜单中隐藏。适用于只应由其他代理通过 Task 工具以编程方式调用的内部子代理。 + +```json title="opencode.json" +{ + "agent": { + "internal-helper": { + "mode": "subagent", + "hidden": true + } + } +} +``` + +这仅影响自动补全菜单中的用户可见性。如果权限允许,模型仍然可以通过 Task 工具调用隐藏的代理。 + +:::note +仅适用于 `mode: subagent` 的代理。 +::: + +--- + +### 任务权限 + +使用 `permission.task` 控制代理可以通过 Task 工具调用哪些子代理。使用 glob 模式进行灵活匹配。 + +```json title="opencode.json" +{ + "agent": { + "orchestrator": { + "mode": "primary", + "permission": { + "task": { + "*": "deny", + "orchestrator-*": "allow", + "code-reviewer": "ask" + } + } + } + } +} +``` + +当设置为 `deny` 时,子代理将从 Task 工具描述中完全移除,因此模型不会尝试调用它。 + +:::tip +规则按顺序评估,**最后匹配的规则优先**。在上面的示例中,`orchestrator-planner` 同时匹配 `*`(deny)和 `orchestrator-*`(allow),但由于 `orchestrator-*` 在 `*` 之后,所以结果为 `allow`。 +::: + +:::tip +用户始终可以通过 `@` 自动补全菜单直接调用任何子代理,即使代理的任务权限会拒绝它。 +::: + +--- + +### 颜色 + +使用 `color` 选项自定义代理在 UI 中的视觉外观。这会影响代理在界面中的显示方式。 + +使用有效的十六进制颜色(例如 `#FF5733`)或主题颜色:`primary`、`secondary`、`accent`、`success`、`warning`、`error`、`info`。 + +```json title="opencode.json" +{ + "agent": { + "creative": { + "color": "#ff6b6b" + }, + "code-reviewer": { + "color": "accent" + } + } +} +``` + +--- + +### Top P + +使用 `top_p` 选项控制响应多样性。这是控制随机性的温度替代方案。 + +```json title="opencode.json" +{ + "agent": { + "brainstorm": { + "top_p": 0.9 + } + } +} +``` + +值范围从 0.0 到 1.0。较低的值更加集中,较高的值更加多样化。 + +--- + +### 其他选项 + +您在代理配置中指定的任何其他选项都将作为模型选项**直接传递**给提供商。这允许您使用提供商特定的功能和参数。 + +例如,使用 OpenAI 的推理模型时,您可以控制推理力度: + +```json title="opencode.json" {6,7} +{ + "agent": { + "deep-thinker": { + "description": "Agent that uses high reasoning effort for complex problems", + "model": "openai/gpt-5", + "reasoningEffort": "high", + "textVerbosity": "low" + } + } +} +``` + +这些附加选项是模型和提供商特定的。请查阅您的提供商文档以获取可用参数。 + +:::tip +运行 `opencode models` 查看可用模型列表。 +::: + +--- + +## 创建代理 + +您可以使用以下命令创建新代理: + +```bash +opencode agent create +``` + +此交互式命令将: + +1. 询问代理的保存位置——全局或项目级。 +2. 描述代理应该做什么。 +3. 生成合适的系统提示词和标识符。 +4. 让您选择代理可以访问哪些工具。 +5. 最后,创建一个包含代理配置的 Markdown 文件。 + +--- + +## 使用场景 + +以下是不同代理的一些常见使用场景。 + +- **Build 代理**:启用所有工具的完整开发工作 +- **Plan 代理**:分析和规划,不进行任何更改 +- **Review 代理**:具有只读访问权限和文档工具的代码审查 +- **Debug 代理**:专注于问题排查,启用 bash 和读取工具 +- **Docs 代理**:文档编写,具有文件操作但不使用系统命令 + +--- + +## 示例 + +以下是一些您可能会觉得有用的示例代理。 + +:::tip +您有想要分享的代理吗?[提交 PR](https://github.com/anomalyco/opencode)。 +::: + +--- + +### 文档代理 + +```markdown title="~/.config/opencode/agents/docs-writer.md" +--- +description: Writes and maintains project documentation +mode: subagent +tools: + bash: false +--- + +You are a technical writer. Create clear, comprehensive documentation. + +Focus on: + +- Clear explanations +- Proper structure +- Code examples +- User-friendly language +``` + +--- + +### 安全审计代理 + +```markdown title="~/.config/opencode/agents/security-auditor.md" +--- +description: Performs security audits and identifies vulnerabilities +mode: subagent +tools: + write: false + edit: false +--- + +You are a security expert. Focus on identifying potential security issues. + +Look for: + +- Input validation vulnerabilities +- Authentication and authorization flaws +- Data exposure risks +- Dependency vulnerabilities +- Configuration security issues +``` diff --git a/packages/web/src/content/docs/zh-cn/cli.mdx b/packages/web/src/content/docs/zh-cn/cli.mdx new file mode 100644 index 0000000000000000000000000000000000000000..46f090bb72ab3e06e43a397cf237bfe2fead0563 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/cli.mdx @@ -0,0 +1,617 @@ +--- +title: CLI +description: OpenCode CLI 选项和命令。 +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" + +OpenCode CLI 在不带任何参数运行时,默认启动 [TUI](/docs/tui)。 + +```bash +opencode +``` + +但它也接受本页面中记录的命令,使您可以通过编程方式与 OpenCode 进行交互。 + +```bash +opencode run "Explain how closures work in JavaScript" +``` + +--- + +### tui + +启动 OpenCode 终端用户界面。 + +```bash +opencode [project] +``` + +#### 标志 + +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | --------------------------------------------------------- | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | +| {"--prompt"} | | 要使用的提示词 | +| {"--model"} | `-m` | 要使用的模型,格式为 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--port"} | | 监听端口 | +| {"--hostname"} | | 监听主机名 | + +--- + +## 命令 + +OpenCode CLI 还提供以下命令。 + +--- + +### agent + +管理 OpenCode 的代理。 + +```bash +opencode agent [command] +``` + +--- + +### attach + +将终端连接到已通过 `serve` 或 `web` 命令启动的 OpenCode 后端服务器。 + +```bash +opencode attach [url] +``` + +这允许将 TUI 与远程 OpenCode 后端配合使用。例如: + +```bash +# Start the backend server for web/mobile access +opencode web --port 4096 --hostname 0.0.0.0 + +# In another terminal, attach the TUI to the running backend +opencode attach http://10.20.30.40:4096 +``` + +#### 标志 + +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--dir"} | | 启动 TUI 的工作目录 | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时派生会话(与 `--continue` 或 `--session` 一起使用) | +| {"--password"} | `-p` | 基本认证密码(默认使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本认证用户名(默认使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | + +--- + +#### create + +使用自定义配置创建新的代理。 + +```bash +opencode agent create +``` + +此命令将引导您使用自定义系统提示词和工具配置来创建新的代理。 + +--- + +#### list + +列出所有可用的代理。 + +```bash +opencode agent list +``` + +--- + +### auth + +管理提供商的凭据和登录信息的命令。 + +```bash +opencode auth [command] +``` + +--- + +#### login + +OpenCode 基于 [Models.dev](https://models.dev) 的提供商列表运行,因此您可以使用 `opencode auth login` 为任何想要使用的提供商配置 API 密钥。密钥存储在 `~/.local/share/opencode/auth.json` 中。 + +```bash +opencode auth login +``` + +OpenCode 启动时会从凭据文件加载提供商信息,同时也会加载环境变量或项目中 `.env` 文件中定义的密钥。 + +--- + +#### list + +列出凭据文件中存储的所有已认证提供商。 + +```bash +opencode auth list +``` + +或使用简写版本。 + +```bash +opencode auth ls +``` + +--- + +#### logout + +从凭据文件中清除提供商信息以完成登出。 + +```bash +opencode auth logout +``` + +--- + +### github + +管理用于仓库自动化的 GitHub 代理。 + +```bash +opencode github [command] +``` + +--- + +#### install + +在您的仓库中安装 GitHub 代理。 + +```bash +opencode github install +``` + +此命令会设置必要的 GitHub Actions 工作流并引导您完成配置过程。[了解更多](/docs/github)。 + +--- + +#### run + +运行 GitHub 代理。通常在 GitHub Actions 中使用。 + +```bash +opencode github run +``` + +##### 标志 + +| 标志 | 描述 | +| ------------------------------------- | ------------------------------ | +| {"--event"} | 用于运行代理的 GitHub 模拟事件 | +| {"--token"} | GitHub 个人访问令牌 | + +--- + +### mcp + +管理 Model Context Protocol 服务器。 + +```bash +opencode mcp [command] +``` + +--- + +#### add + +将 MCP 服务器添加到您的配置中。 + +```bash +opencode mcp add +``` + +此命令将引导您添加本地或远程 MCP 服务器。 + +--- + +#### list + +列出所有已配置的 MCP 服务器及其连接状态。 + +```bash +opencode mcp list +``` + +或使用简写版本。 + +```bash +opencode mcp ls +``` + +--- + +#### auth + +对支持 OAuth 的 MCP 服务器进行认证。 + +```bash +opencode mcp auth [name] +``` + +如果您不提供服务器名称,系统将提示您从可用的支持 OAuth 的服务器中进行选择。 + +您还可以列出支持 OAuth 的服务器及其认证状态。 + +```bash +opencode mcp auth list +``` + +或使用简写版本。 + +```bash +opencode mcp auth ls +``` + +--- + +#### logout + +移除 MCP 服务器的 OAuth 凭据。 + +```bash +opencode mcp logout [name] +``` + +--- + +#### debug + +调试 MCP 服务器的 OAuth 连接问题。 + +```bash +opencode mcp debug +``` + +--- + +### models + +列出已配置提供商的所有可用模型。 + +```bash +opencode models [provider] +``` + +此命令以 `provider/model` 的格式显示所有已配置提供商中可用的模型。 + +这对于确定在[配置文件](/docs/config/)中使用的确切模型名称非常有用。 + +您可以选择传入提供商 ID 来按提供商筛选模型。 + +```bash +opencode models anthropic +``` + +#### 标志 + +| 标志 | 描述 | +| --------------------------------------- | ---------------------------------------- | +| {"--refresh"} | 从 models.dev 刷新模型缓存 | +| {"--verbose"} | 使用更详细的模型输出(包含费用等元数据) | + +使用 `--refresh` 标志可以更新缓存的模型列表。当提供商新增了模型并且您希望在 OpenCode 中看到它们时,此功能非常有用。 + +```bash +opencode models --refresh +``` + +--- + +### run + +以非交互模式运行 OpenCode,直接传入提示词。 + +```bash +opencode run [message..] +``` + +这对于脚本编写、自动化或无需启动完整 TUI 即可快速获取答案的场景非常有用。例如: + +```bash "opencode run" +opencode run Explain the use of context in Go +``` + +您还可以连接到正在运行的 `opencode serve` 实例,以避免每次运行时 MCP 服务器的冷启动时间: + +```bash +# Start a headless server in one terminal +opencode serve + +# In another terminal, run commands that attach to it +opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" +``` + +#### 标志 + +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--command"} | | 要运行的命令,使用 message 作为参数 | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | +| {"--share"} | | 分享会话 | +| {"--model"} | `-m` | 要使用的模型,格式为 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--file"} | `-f` | 附加到消息的文件 | +| {"--format"} | | 格式:default(格式化输出)或 json(原始 JSON 事件) | +| {"--title"} | | 会话标题(未提供值时使用截断的提示词) | +| {"--attach"} | | 连接到正在运行的 opencode 服务器(例如 http://localhost:4096) | +| {"--password"} | `-p` | 基本认证密码(默认使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本认证用户名(默认使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | +| {"--dir"} | | 运行目录,或附加时远程服务器上的路径 | +| {"--variant"} | | 模型变体(特定于提供商的推理级别) | +| {"--thinking"} | | 显示思考块 | +| {"--port"} | | 本地服务器端口(默认为随机端口) | + +--- + +### serve + +启动无界面的 OpenCode 服务器以提供 API 访问。查看[服务器文档](/docs/server)了解完整的 HTTP 接口。 + +```bash +opencode serve +``` + +此命令启动一个 HTTP 服务器,提供对 OpenCode 功能的 API 访问,无需 TUI 界面。设置 `OPENCODE_SERVER_PASSWORD` 可启用 HTTP 基本认证(用户名默认为 `opencode`)。 + +#### 标志 + +| 标志 | 描述 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | +| {"--mdns"} | 启用 mDNS 发现 | +| {"--cors"} | 允许 CORS 的额外浏览器来源 | + +--- + +### session + +管理 OpenCode 会话。 + +```bash +opencode session [command] +``` + +--- + +#### list + +列出所有 OpenCode 会话。 + +```bash +opencode session list +``` + +##### 标志 + +| 标志 | 简写 | 描述 | +| ----------------------------------------- | ---- | ------------------------------------- | +| {"--max-count"} | `-n` | 限制为最近 N 个会话 | +| {"--format"} | | 输出格式:table 或 json(默认 table) | + +--- + +### stats + +显示 OpenCode 会话的 Token 用量和费用统计信息。 + +```bash +opencode stats +``` + +#### 标志 + +| 标志 | 描述 | +| --------------------------------------- | ------------------------------------------------------ | +| {"--days"} | 显示最近 N 天的统计信息(默认为所有时间) | +| {"--tools"} | 显示的工具数量(默认为全部) | +| {"--models"} | 显示模型用量明细(默认隐藏)。传入数字可显示前 N 个 | +| {"--project"} | 按项目筛选(默认为所有项目,传入空字符串表示当前项目) | + +--- + +### export + +将会话数据导出为 JSON。 + +```bash +opencode export [sessionID] +``` + +如果您不提供会话 ID,系统将提示您从可用的会话中进行选择。 + +--- + +### import + +从 JSON 文件或 OpenCode 分享链接导入会话数据。 + +```bash +opencode import +``` + +您可以从本地文件或 OpenCode 分享链接导入。 + +```bash +opencode import session.json +opencode import https://opncd.ai/s/abc123 +``` + +--- + +### web + +启动带有 Web 界面的无界面 OpenCode 服务器。 + +```bash +opencode web +``` + +此命令启动一个 HTTP 服务器并打开浏览器,通过 Web 界面访问 OpenCode。设置 `OPENCODE_SERVER_PASSWORD` 可启用 HTTP 基本认证(用户名默认为 `opencode`)。 + +#### 标志 + +| 标志 | 描述 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | +| {"--mdns"} | 启用 mDNS 发现 | +| {"--cors"} | 允许 CORS 的额外浏览器来源 | + +--- + +### acp + +启动 ACP(Agent Client Protocol)服务器。 + +```bash +opencode acp +``` + +此命令启动一个通过 stdin/stdout 使用 nd-JSON 进行通信的 ACP 服务器。 + +#### 标志 + +| 标志 | 描述 | +| ---------------------------------------- | ---------- | +| {"--cwd"} | 工作目录 | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | + +--- + +### uninstall + +卸载 OpenCode 并删除所有相关文件。 + +```bash +opencode uninstall +``` + +#### 标志 + +| 标志 | 简写 | 描述 | +| ------------------------------------------- | ---- | ------------------------------ | +| {"--keep-config"} | `-c` | 保留配置文件 | +| {"--keep-data"} | `-d` | 保留会话数据和快照 | +| {"--dry-run"} | | 显示将被删除的内容但不实际删除 | +| {"--force"} | `-f` | 跳过确认提示 | + +--- + +### upgrade + +将 OpenCode 更新到最新版本或指定版本。 + +```bash +opencode upgrade [target] +``` + +更新到最新版本。 + +```bash +opencode upgrade +``` + +更新到指定版本。 + +```bash +opencode upgrade v0.1.48 +``` + +#### 标志 + +| 标志 | 简写 | 描述 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 使用的安装方式:curl、npm、pnpm、bun、brew | + +--- + +## 全局标志 + +OpenCode CLI 接受以下全局标志。 + +| 标志 | 简写 | 描述 | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | 显示帮助信息 | +| {"--version"} | `-v` | 打印版本号 | +| {"--print-logs"} | | 将日志输出到 stderr | +| {"--log-level"} | | 日志级别(DEBUG、INFO、WARN、ERROR) | + +--- + +## 环境变量 + +OpenCode 可以通过环境变量进行配置。 + +| 变量 | 类型 | 描述 | +| ------------------------------------- | ------- | --------------------------------------- | +| `OPENCODE_AUTO_SHARE` | boolean | 自动分享会话 | +| `OPENCODE_GIT_BASH_PATH` | string | Windows 上 Git Bash 可执行文件的路径 | +| `OPENCODE_CONFIG` | string | 配置文件路径 | +| `OPENCODE_TUI_CONFIG` | string | TUI 配置文件路径 | +| `OPENCODE_CONFIG_DIR` | string | 配置目录路径 | +| `OPENCODE_CONFIG_CONTENT` | string | 内联 JSON 配置内容 | +| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | 禁用自动更新检查 | +| `OPENCODE_DISABLE_PRUNE` | boolean | 禁用旧数据清理 | +| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | 禁用自动终端标题更新 | +| `OPENCODE_PERMISSION` | string | 内联 JSON 权限配置 | +| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | 禁用默认插件 | +| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | 禁用 LSP 服务器自动下载 | +| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | 启用实验性模型 | +| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | 禁用自动上下文压缩 | +| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | 禁用读取 `.claude`(提示词 + 技能) | +| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | 禁用读取 `~/.claude/CLAUDE.md` | +| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | 禁用加载 `.claude/skills` | +| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | 禁用从远程源获取模型 | +| `OPENCODE_FAKE_VCS` | string | 用于测试目的的模拟 VCS 提供商 | +| `OPENCODE_CLIENT` | string | 客户端标识符(默认为 `cli`) | +| `OPENCODE_ENABLE_EXA` | boolean | 启用 Exa 网络搜索工具 | +| `OPENCODE_SERVER_PASSWORD` | string | 为 `serve`/`web` 启用基本认证 | +| `OPENCODE_SERVER_USERNAME` | string | 覆盖基本认证用户名(默认为 `opencode`) | +| `OPENCODE_MODELS_URL` | string | 自定义模型配置获取 URL | + +--- + +### 实验性功能 + +这些环境变量用于启用可能会更改或移除的实验性功能。 + +| 变量 | 类型 | 描述 | +| ----------------------------------------------- | ------- | ------------------------------- | +| `OPENCODE_EXPERIMENTAL` | boolean | 启用受总开关控制的实验性功能 | +| `OPENCODE_EXPERIMENTAL_ICON_DISCOVERY` | boolean | 启用图标发现 | +| `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT` | boolean | 禁用 TUI 中的选中即复制 | +| `OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` | number | bash 命令的默认超时时间(毫秒) | +| `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | number | LLM 响应的最大输出 Token 数 | +| `OPENCODE_EXPERIMENTAL_FILEWATCHER` | boolean | 启用整个目录的文件监听器 | +| `OPENCODE_EXPERIMENTAL_OXFMT` | boolean | 启用 oxfmt 格式化器 | +| `OPENCODE_EXPERIMENTAL_LSP_TOOL` | boolean | 启用实验性 LSP 工具 | +| `OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER` | boolean | 禁用文件监听器 | +| `OPENCODE_EXPERIMENTAL_EXA` | boolean | 启用实验性 Exa 功能 | +| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | 为 python 文件启用 TY LSP | +| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | 启用计划模式 | +| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | 启用后台子代理任务 | +| `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | 启用实验性事件系统 | +| `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | 启用原生 LLM 请求路径 | +| `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | 启用并行 Web 搜索执行 | +| `OPENCODE_EXPERIMENTAL_SCOUT` | boolean | 启用 Scout 子代理 | +| `OPENCODE_EXPERIMENTAL_WORKSPACES` | boolean | 启用工作区支持 | diff --git a/packages/web/src/content/docs/zh-cn/commands.mdx b/packages/web/src/content/docs/zh-cn/commands.mdx new file mode 100644 index 0000000000000000000000000000000000000000..751a9ff27b1816ee083be9bc3ab388ed46b36e02 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/commands.mdx @@ -0,0 +1,322 @@ +--- +title: 命令 +description: 为重复任务创建自定义命令。 +--- + +自定义命令允许你指定一个提示词,当在 TUI 中执行该命令时会运行这个提示词。 + +```bash frame="none" +/my-command +``` + +自定义命令是 `/init`、`/undo`、`/redo`、`/share`、`/help` 等内置命令之外的补充。[了解更多](/docs/tui#commands)。 + +--- + +## 创建命令文件 + +在 `commands/` 目录中创建 markdown 文件来定义自定义命令。 + +创建 `.opencode/commands/test.md`: + +```md title=".opencode/commands/test.md" +--- +description: Run tests with coverage +agent: build +model: anthropic/claude-3-5-sonnet-20241022 +--- + +Run the full test suite with coverage report and show any failures. +Focus on the failing tests and suggest fixes. +``` + +frontmatter 定义命令属性,内容则成为模板。 + +通过输入 `/` 后跟命令名称来使用该命令。 + +```bash frame="none" +"/test" +``` + +--- + +## 配置 + +你可以通过 OpenCode 配置或在 `commands/` 目录中创建 markdown 文件来添加自定义命令。 + +--- + +### JSON + +在 OpenCode [配置](/docs/config)中使用 `command` 选项: + +```json title="opencode.jsonc" {4-12} +{ + "$schema": "https://opencode.ai/config.json", + "command": { + // This becomes the name of the command + "test": { + // This is the prompt that will be sent to the LLM + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.", + // This is shown as the description in the TUI + "description": "Run tests with coverage", + "agent": "build", + "model": "anthropic/claude-3-5-sonnet-20241022" + } + } +} +``` + +现在你可以在 TUI 中运行这个命令: + +```bash frame="none" +/test +``` + +--- + +### Markdown + +你还可以使用 markdown 文件定义命令。将它们放在: + +- 全局:`~/.config/opencode/commands/` +- 项目级:`.opencode/commands/` + +```markdown title="~/.config/opencode/commands/test.md" +--- +description: Run tests with coverage +agent: build +model: anthropic/claude-3-5-sonnet-20241022 +--- + +Run the full test suite with coverage report and show any failures. +Focus on the failing tests and suggest fixes. +``` + +markdown 文件名即为命令名。例如,`test.md` 允许你运行: + +```bash frame="none" +/test +``` + +--- + +## 提示词配置 + +自定义命令的提示词支持多种特殊占位符和语法。 + +--- + +### 参数 + +使用 `$ARGUMENTS` 占位符向命令传递参数。 + +```md title=".opencode/commands/component.md" +--- +description: Create a new component +--- + +Create a new React component named $ARGUMENTS with TypeScript support. +Include proper typing and basic structure. +``` + +带参数运行命令: + +```bash frame="none" +/component Button +``` + +`$ARGUMENTS` 将被替换为 `Button`。 + +你还可以使用位置参数访问各个参数: + +- `$1` - 第一个参数 +- `$2` - 第二个参数 +- `$3` - 第三个参数 +- 以此类推... + +例如: + +```md title=".opencode/commands/create-file.md" +--- +description: Create a new file with content +--- + +Create a file named $1 in the directory $2 +with the following content: $3 +``` + +运行命令: + +```bash frame="none" +/create-file config.json src "{ \"key\": \"value\" }" +``` + +替换结果为: + +- `$1` 替换为 `config.json` +- `$2` 替换为 `src` +- `$3` 替换为 `{ "key": "value" }` + +--- + +### Shell 输出 + +使用 _!`command`_ 将 [bash 命令](/docs/tui#bash-commands)输出注入到提示词中。 + +例如,创建一个分析测试覆盖率的自定义命令: + +```md title=".opencode/commands/analyze-coverage.md" +--- +description: Analyze test coverage +--- + +Here are the current test results: +!`npm test` + +Based on these results, suggest improvements to increase coverage. +``` + +或者查看最近的更改: + +```md title=".opencode/commands/review-changes.md" +--- +description: Review recent changes +--- + +Recent git commits: +!`git log --oneline -10` + +Review these changes and suggest any improvements. +``` + +命令在项目的根目录中运行,其输出会成为提示词的一部分。 + +--- + +### 文件引用 + +使用 `@` 后跟文件名在命令中引用文件。 + +```md title=".opencode/commands/review-component.md" +--- +description: Review component +--- + +Review the component in @src/components/Button.tsx. +Check for performance issues and suggest improvements. +``` + +文件内容会自动包含在提示词中。 + +--- + +## 选项 + +让我们详细了解各配置选项。 + +--- + +### Template + +`template` 选项定义执行命令时发送给 LLM 的提示词。 + +```json title="opencode.json" +{ + "command": { + "test": { + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes." + } + } +} +``` + +这是一个**必需的**配置选项。 + +--- + +### Description + +使用 `description` 选项提供命令功能的简要描述。 + +```json title="opencode.json" +{ + "command": { + "test": { + "description": "Run tests with coverage" + } + } +} +``` + +当你输入命令时,这将在 TUI 中显示为描述。 + +--- + +### Agent + +使用 `agent` 配置可选地指定由哪个[代理](/docs/agents)执行此命令。 +如果这是一个[子代理](/docs/agents/#subagents),该命令默认会触发子代理调用。 +要禁用此行为,请将 `subtask` 设置为 `false`。 + +```json title="opencode.json" +{ + "command": { + "review": { + "agent": "plan" + } + } +} +``` + +这是一个**可选的**配置选项。如果未指定,默认使用你当前的代理。 + +--- + +### Subtask + +使用 `subtask` 布尔值强制命令触发[子代理](/docs/agents/#subagents)调用。 +如果你希望命令不污染主要上下文,这会很有用,它会**强制**代理作为子代理运行, +即使[代理](/docs/agents)配置中的 `mode` 设置为 `primary`。 + +```json title="opencode.json" +{ + "command": { + "analyze": { + "subtask": true + } + } +} +``` + +这是一个**可选的**配置选项。 + +--- + +### Model + +使用 `model` 配置覆盖此命令的默认模型。 + +```json title="opencode.json" +{ + "command": { + "analyze": { + "model": "anthropic/claude-3-5-sonnet-20241022" + } + } +} +``` + +这是一个**可选的**配置选项。 + +--- + +## 内置命令 + +opencode 包含多个内置命令,如 `/init`、`/undo`、`/redo`、`/share`、`/help`;[了解更多](/docs/tui#commands)。 + +:::note +自定义命令可以覆盖内置命令。 +::: + +如果你定义了同名的自定义命令,它将覆盖内置命令。 diff --git a/packages/web/src/content/docs/zh-cn/config.mdx b/packages/web/src/content/docs/zh-cn/config.mdx new file mode 100644 index 0000000000000000000000000000000000000000..ffae7966f308c0a389addebccd1a0cbbe9fbb2b9 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/config.mdx @@ -0,0 +1,683 @@ +--- +title: 配置 +description: 使用 OpenCode JSON 配置。 +--- + +您可以使用 JSON 配置文件来配置 OpenCode。 + +--- + +## 格式 + +OpenCode 支持 **JSON** 和 **JSONC**(带注释的 JSON)格式。 + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-5", + "autoupdate": true, + "server": { + "port": 4096, + }, +} +``` + +--- + +## 位置 + +您可以将配置放置在不同的位置,它们具有不同的优先级顺序。 + +:::note +配置文件是**合并在一起**的,而不是替换。 +::: + +配置文件是合并在一起的,而不是被替换。来自以下配置位置的设置会被合并。后面的配置仅在键冲突时覆盖前面的配置。所有配置中的非冲突设置都会被保留。 + +例如,如果您的全局配置设置了 `autoupdate: true`,而您的项目配置设置了 `model: "anthropic/claude-sonnet-4-5"`,则最终配置将包含这两个设置。 + +--- + +### 优先级顺序 + +配置源按以下顺序加载(后面的源覆盖前面的源): + +1. **远程配置**(来自 `.well-known/opencode`)- 组织默认值 +2. **全局配置**(`~/.config/opencode/opencode.json`)- 用户偏好 +3. **自定义配置**(`OPENCODE_CONFIG` 环境变量)- 自定义覆盖 +4. **项目配置**(项目中的 `opencode.json`)- 项目特定设置 +5. **`.opencode` 目录** - 代理、命令、插件 +6. **内联配置**(`OPENCODE_CONFIG_CONTENT` 环境变量)- 运行时覆盖 + +这意味着项目配置可以覆盖全局默认值,全局配置可以覆盖远程组织默认值。 + +:::note +`.opencode` 和 `~/.config/opencode` 目录的子目录使用**复数名称**:`agents/`、`commands/`、`modes/`、`plugins/`、`skills/`、`tools/` 和 `themes/`。为了向后兼容,也支持单数名称(例如 `agent/`)。 +::: + +--- + +### 远程 + +组织可以通过 `.well-known/opencode` 端点提供默认配置。当您使用支持该功能的提供商进行身份验证时,会自动获取此配置。 + +远程配置最先加载,作为基础层。所有其他配置源(全局、项目)都可以覆盖这些默认值。 + +例如,如果您的组织提供了默认禁用的 MCP 服务器: + +```json title="Remote config from .well-known/opencode" +{ + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": false + } + } +} +``` + +您可以在本地配置中启用特定服务器: + +```json title="opencode.json" +{ + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } + } +} +``` + +--- + +### 全局 + +将全局 OpenCode 配置放在 `~/.config/opencode/opencode.json` 中。使用全局配置来设置用户级别的偏好,例如主题、提供商或快捷键。 + +全局配置覆盖远程组织默认值。 + +--- + +### 项目级 + +在项目根目录中添加 `opencode.json`。项目配置在标准配置文件中具有最高优先级——它会覆盖全局配置和远程配置。 + +:::tip +将项目特定配置放在项目的根目录中。 +::: + +当 OpenCode 启动时,它会在当前目录中查找配置文件,或向上遍历到最近的 Git 目录。 + +该配置文件也可以安全地提交到 Git 中,并使用与全局配置相同的 Schema。 + +--- + +### 自定义路径 + +使用 `OPENCODE_CONFIG` 环境变量指定自定义配置文件路径。 + +```bash +export OPENCODE_CONFIG=/path/to/my/custom-config.json +opencode run "Hello world" +``` + +自定义配置在优先级顺序中位于全局配置和项目配置之间加载。 + +--- + +### 自定义目录 + +使用 `OPENCODE_CONFIG_DIR` 环境变量指定自定义配置目录。该目录会像标准 `.opencode` 目录一样被搜索代理、命令、模式和插件,并且应遵循相同的结构。 + +```bash +export OPENCODE_CONFIG_DIR=/path/to/my/config-directory +opencode run "Hello world" +``` + +自定义目录在全局配置和 `.opencode` 目录之后加载,因此**可以覆盖**它们的设置。 + +--- + +## Schema + +配置文件具有在 [**`opencode.ai/config.json`**](https://opencode.ai/config.json) 中定义的 Schema。 + +您的编辑器应该能够基于该 Schema 进行验证和自动补全。 + +--- + +### TUI + +您可以通过 `tui` 选项配置 TUI 相关设置。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "tui": { + "scroll_speed": 3, + "scroll_acceleration": { + "enabled": true + }, + "diff_style": "auto" + } +} +``` + +可用选项: + +- `scroll_acceleration.enabled` - 启用 macOS 风格的滚动加速。**优先于 `scroll_speed`。** +- `scroll_speed` - 自定义滚动速度倍率(默认值:`3`,最小值:`1`)。如果 `scroll_acceleration.enabled` 为 `true`,则忽略此选项。 +- `diff_style` - 控制差异渲染方式。`"auto"` 根据终端宽度自适应,`"stacked"` 始终显示单列。 + +[在此了解更多关于 TUI 的信息](/docs/tui)。 + +--- + +### 服务器 + +您可以通过 `server` 选项为 `opencode serve` 和 `opencode web` 命令配置服务器设置。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true, + "mdnsDomain": "myproject.local", + "cors": ["http://localhost:5173"] + } +} +``` + +可用选项: + +- `port` - 监听端口。 +- `hostname` - 监听主机名。当 `mdns` 启用且未设置主机名时,默认为 `0.0.0.0`。 +- `mdns` - 启用 mDNS 服务发现。这允许网络上的其他设备发现您的 OpenCode 服务器。 +- `mdnsDomain` - mDNS 服务的自定义域名。默认为 `opencode.local`。适用于在同一网络上运行多个实例的场景。 +- `cors` - 从基于浏览器的客户端使用 HTTP 服务器时允许 CORS 的额外来源。值必须是完整的来源(协议 + 主机 + 可选端口),例如 `https://app.example.com`。 + +[在此了解更多关于服务器的信息](/docs/server)。 + +--- + +### 工具 + +您可以通过 `tools` 选项管理 LLM 可以使用的工具。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "tools": { + "write": false, + "bash": false + } +} +``` + +[在此了解更多关于工具的信息](/docs/tools)。 + +--- + +### 模型 + +您可以通过 `provider`、`model` 和 `small_model` 选项在 OpenCode 配置中设置要使用的提供商和模型。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": {}, + "model": "anthropic/claude-sonnet-4-5", + "small_model": "anthropic/claude-haiku-4-5" +} +``` + +`small_model` 选项为标题生成等轻量级任务配置单独的模型。默认情况下,如果您的提供商有更便宜的模型可用,OpenCode 会尝试使用该模型,否则会回退到您的主模型。 + +提供商选项可以包括 `timeout` 和 `setCacheKey`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "timeout": 600000, + "setCacheKey": true + } + } + } +} +``` + +- `timeout` - 请求超时时间,单位为毫秒(默认值:300000)。设置为 `false` 可禁用超时。 +- `setCacheKey` - 确保始终为指定提供商设置缓存键。 + +您还可以配置[本地模型](/docs/models#local)。[了解更多](/docs/models)。 + +--- + +#### 提供商特定选项 + +一些提供商支持除通用 `timeout` 和 `apiKey` 设置之外的额外配置选项。 + +##### Amazon Bedrock + +Amazon Bedrock 支持 AWS 特定配置: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "my-aws-profile", + "endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" + } + } + } +} +``` + +- `region` - Bedrock 的 AWS 区域(默认为 `AWS_REGION` 环境变量或 `us-east-1`) +- `profile` - 来自 `~/.aws/credentials` 的 AWS 命名配置文件(默认为 `AWS_PROFILE` 环境变量) +- `endpoint` - VPC 端点的自定义端点 URL。这是通用 `baseURL` 选项使用 AWS 特定术语的别名。如果两者都指定,`endpoint` 优先。 + +:::note +Bearer Token(`AWS_BEARER_TOKEN_BEDROCK` 或 `/connect`)优先于基于配置文件的身份验证。详情请参见[身份验证优先级](/docs/providers#authentication-precedence)。 +::: + +[了解更多关于 Amazon Bedrock 配置的信息](/docs/providers#amazon-bedrock)。 + +--- + +### 主题 + +您可以通过 OpenCode 配置中的 `theme` 选项设置要使用的主题。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "theme": "" +} +``` + +[在此了解更多](/docs/themes)。 + +--- + +### 代理 + +您可以通过 `agent` 选项为特定任务配置专用代理。 + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "code-reviewer": { + "description": "Reviews code for best practices and potential issues", + "model": "anthropic/claude-sonnet-4-5", + "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", + "tools": { + // Disable file modification tools for review-only agent + "write": false, + "edit": false, + }, + }, + }, +} +``` + +您还可以使用 `~/.config/opencode/agents/` 或 `.opencode/agents/` 中的 Markdown 文件定义代理。[在此了解更多](/docs/agents)。 + +--- + +### 默认代理 + +您可以使用 `default_agent` 选项设置默认代理。当未明确指定代理时,将使用该默认代理。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "default_agent": "plan" +} +``` + +默认代理必须是主代理(不能是子代理)。可以是内置代理(如 `"build"` 或 `"plan"`),也可以是您定义的[自定义代理](/docs/agents)。如果指定的代理不存在或是子代理,OpenCode 将回退到 `"build"` 并发出警告。 + +此设置适用于所有界面:TUI、CLI(`opencode run`)、桌面应用和 GitHub Action。 + +--- + +### 分享 + +您可以通过 `share` 选项配置[分享](/docs/share)功能。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "manual" +} +``` + +该选项接受: + +- `"manual"` - 允许通过命令手动分享(默认) +- `"auto"` - 自动分享新会话 +- `"disabled"` - 完全禁用分享 + +默认情况下,分享设置为手动模式,您需要使用 `/share` 命令显式分享会话。 + +--- + +### 命令 + +您可以通过 `command` 选项为重复任务配置自定义命令。 + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "command": { + "test": { + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.", + "description": "Run tests with coverage", + "agent": "build", + "model": "anthropic/claude-haiku-4-5", + }, + "component": { + "template": "Create a new React component named $ARGUMENTS with TypeScript support.\nInclude proper typing and basic structure.", + "description": "Create a new component", + }, + }, +} +``` + +您还可以使用 `~/.config/opencode/commands/` 或 `.opencode/commands/` 中的 Markdown 文件定义命令。[在此了解更多](/docs/commands)。 + +--- + +### 快捷键 + +您可以通过 `keybinds` 选项自定义快捷键。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "keybinds": {} +} +``` + +[在此了解更多](/docs/keybinds)。 + +--- + +### 自动更新 + +OpenCode 启动时会自动下载新版本。您可以使用 `autoupdate` 选项禁用此功能。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "autoupdate": false +} +``` + +如果您不想自动更新但希望在新版本可用时收到通知,可将 `autoupdate` 设置为 `"notify"`。 +请注意,此功能仅在未通过 Homebrew 等包管理器安装时有效。 + +--- + +### 格式化程序 + +您可以通过 `formatter` 选项配置代码格式化程序。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "disabled": true + }, + "custom-prettier": { + "command": ["npx", "prettier", "--write", "$FILE"], + "environment": { + "NODE_ENV": "development" + }, + "extensions": [".js", ".ts", ".jsx", ".tsx"] + } + } +} +``` + +[在此了解更多关于格式化程序的信息](/docs/formatters)。 + +--- + +### 权限 + +默认情况下,OpenCode **允许所有操作**,无需明确批准。您可以使用 `permission` 选项更改此行为。 + +例如,要让 `edit` 和 `bash` 工具需要用户确认: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "ask", + "bash": "ask" + } +} +``` + +[在此了解更多关于权限的信息](/docs/permissions)。 + +--- + +### 压缩 + +您可以通过 `compaction` 选项控制上下文压缩行为。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "compaction": { + "auto": true, + "prune": false, + "reserved": 10000 + } +} +``` + +- `auto` - 当上下文已满时自动压缩会话(默认值:`true`)。 +- `prune` - 删除旧的工具输出以节省 Token(默认值:`false`)。 +- `reserved` - 压缩时的 Token 缓冲区。保留足够的窗口以避免压缩过程中溢出。 + +--- + +### 文件监视器 + +您可以通过 `watcher` 选项配置文件监视器的忽略模式。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "watcher": { + "ignore": ["node_modules/**", "dist/**", ".git/**"] + } +} +``` + +模式遵循 glob 语法。使用此选项可以从文件监视中排除频繁变动的目录。 + +--- + +### MCP 服务器 + +您可以通过 `mcp` 选项配置要使用的 MCP 服务器。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": {} +} +``` + +[在此了解更多](/docs/mcp-servers)。 + +--- + +### 插件 + +[插件](/docs/plugins)通过自定义工具、钩子和集成来扩展 OpenCode。 + +将插件文件放置在 `.opencode/plugins/` 或 `~/.config/opencode/plugins/` 中。您还可以通过 `plugin` 选项从 npm 加载插件。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-helicone-session", "@my-org/custom-plugin"] +} +``` + +[在此了解更多](/docs/plugins)。 + +--- + +### 指令 + +您可以通过 `instructions` 选项为所使用的模型配置指令。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md"] +} +``` + +该选项接受指令文件路径和 glob 模式的数组。[在此了解更多关于规则的信息](/docs/rules)。 + +--- + +### 禁用提供商 + +您可以通过 `disabled_providers` 选项禁用自动加载的提供商。当您希望阻止某些提供商被加载(即使其凭据可用)时,此选项非常有用。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "disabled_providers": ["openai", "gemini"] +} +``` + +:::note +`disabled_providers` 优先于 `enabled_providers`。 +::: + +`disabled_providers` 选项接受提供商 ID 的数组。当某个提供商被禁用时: + +- 即使设置了环境变量,也不会被加载。 +- 即使通过 `/connect` 命令配置了 API 密钥,也不会被加载。 +- 该提供商的模型不会出现在模型选择列表中。 + +--- + +### 启用提供商 + +您可以通过 `enabled_providers` 选项指定允许使用的提供商白名单。设置后,仅启用指定的提供商,所有其他提供商将被忽略。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "enabled_providers": ["anthropic", "openai"] +} +``` + +当您希望限制 OpenCode 仅使用特定提供商,而不是逐一禁用其他提供商时,此选项非常有用。 + +:::note +`disabled_providers` 优先于 `enabled_providers`。 +::: + +如果某个提供商同时出现在 `enabled_providers` 和 `disabled_providers` 中,为了向后兼容,`disabled_providers` 优先。 + +--- + +### 实验性功能 + +`experimental` 键包含正在积极开发中的选项。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": {} +} +``` + +:::caution +实验性选项不稳定。它们可能会在不另行通知的情况下被更改或移除。 +::: + +--- + +## 变量 + +您可以在配置文件中使用变量替换来引用环境变量和文件内容。 + +--- + +### 环境变量 + +使用 `{env:VARIABLE_NAME}` 来替换环境变量: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "{env:OPENCODE_MODEL}", + "provider": { + "anthropic": { + "models": {}, + "options": { + "apiKey": "{env:ANTHROPIC_API_KEY}" + } + } + } +} +``` + +如果环境变量未设置,它将被替换为空字符串。 + +--- + +### 文件 + +使用 `{file:path/to/file}` 来替换文件内容: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["./custom-instructions.md"], + "provider": { + "openai": { + "options": { + "apiKey": "{file:~/.secrets/openai-key}" + } + } + } +} +``` + +文件路径可以是: + +- 相对于配置文件所在目录的路径 +- 以 `/` 或 `~` 开头的绝对路径 + +这些功能适用于: + +- 将 API 密钥等敏感数据保存在单独的文件中。 +- 引入大型指令文件而不会使配置变得杂乱。 +- 在多个配置文件之间共享通用配置片段。 diff --git a/packages/web/src/content/docs/zh-cn/custom-tools.mdx b/packages/web/src/content/docs/zh-cn/custom-tools.mdx new file mode 100644 index 0000000000000000000000000000000000000000..8b44a0450c2a5ff97026bd78b148552d628ea236 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/custom-tools.mdx @@ -0,0 +1,196 @@ +--- +title: 自定义工具 +description: 创建 LLM 可在 opencode 中调用的工具。 +--- + +自定义工具是你创建的函数,LLM 可以在对话过程中调用它们。它们与 opencode 的[内置工具](/docs/tools)(如 `read`、`write` 和 `bash`)协同工作。 + +--- + +## 创建工具 + +工具以 **TypeScript** 或 **JavaScript** 文件的形式定义。不过,工具定义可以调用**任何语言**编写的脚本——TypeScript 或 JavaScript 仅用于工具定义本身。 + +--- + +### 位置 + +工具可以在以下位置定义: + +- 本地定义:将工具文件放在项目的 `.opencode/tools/` 目录中。 +- 全局定义:将工具文件放在 `~/.config/opencode/tools/` 中。 + +--- + +### 结构 + +创建工具最简单的方式是使用 `tool()` 辅助函数,它提供类型安全和参数校验。 + +```ts title=".opencode/tools/database.ts" {1} +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Query the project database", + args: { + query: tool.schema.string().describe("SQL query to execute"), + }, + async execute(args) { + // Your database logic here + return `Executed query: ${args.query}` + }, +}) +``` + +**文件名**即为**工具名称**。上面的示例创建了一个名为 `database` 的工具。 + +--- + +#### 单文件多工具 + +你也可以从单个文件中导出多个工具。每个导出都会成为**一个独立的工具**,命名格式为 **`_`**: + +```ts title=".opencode/tools/math.ts" +import { tool } from "@opencode-ai/plugin" + +export const add = tool({ + description: "Add two numbers", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args) { + return args.a + args.b + }, +}) + +export const multiply = tool({ + description: "Multiply two numbers", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args) { + return args.a * args.b + }, +}) +``` + +这会创建两个工具:`math_add` 和 `math_multiply`。 + +--- + +#### 与内置工具的名称冲突 + +自定义工具通过工具名称进行索引。如果自定义工具使用了与内置工具相同的名称,则优先使用自定义工具。 + +例如,这个文件取代了内置的bash工具: + +```ts title=".opencode/tools/bash.ts" +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Restricted bash wrapper", + args: { + command: tool.schema.string(), + }, + async execute(args) { + return `blocked: ${args.command}` + }, +}) +``` + +:::note +除非你有意替换内置工具,否则最好用独特的名字。如果你想禁用内置工具但不想覆盖它,使用 [权限](/docs/permissions). +::: + +--- + +### 参数 + +你可以使用 `tool.schema`(即 [Zod](https://zod.dev))来定义参数类型。 + +```ts "tool.schema" +args: { + query: tool.schema.string().describe("SQL query to execute") +} +``` + +你也可以直接导入 [Zod](https://zod.dev) 并返回一个普通对象: + +```ts {6} +import { z } from "zod" + +export default { + description: "Tool description", + args: { + param: z.string().describe("Parameter description"), + }, + async execute(args, context) { + // Tool implementation + return "result" + }, +} +``` + +--- + +### 上下文 + +工具会接收当前会话的上下文信息: + +```ts title=".opencode/tools/project.ts" {8} +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Get project information", + args: {}, + async execute(args, context) { + // Access context information + const { agent, sessionID, messageID, directory, worktree } = context + return `Agent: ${agent}, Session: ${sessionID}, Message: ${messageID}, Directory: ${directory}, Worktree: ${worktree}` + }, +}) +``` + +使用 `context.directory` 获取会话的工作目录。 +使用 `context.worktree` 获取 git worktree 根目录。 + +--- + +## 示例 + +### 用 Python 编写工具 + +你可以使用任何语言编写工具。以下示例展示了如何用 Python 实现两数相加。 + +首先,创建一个 Python 脚本作为工具: + +```python title=".opencode/tools/add.py" +import sys + +a = int(sys.argv[1]) +b = int(sys.argv[2]) +print(a + b) +``` + +然后创建调用该脚本的工具定义: + +```ts title=".opencode/tools/python-add.ts" {10} +import { tool } from "@opencode-ai/plugin" +import path from "path" + +export default tool({ + description: "Add two numbers using Python", + args: { + a: tool.schema.number().describe("First number"), + b: tool.schema.number().describe("Second number"), + }, + async execute(args, context) { + const script = path.join(context.worktree, ".opencode/tools/add.py") + const result = await Bun.$`python3 ${script} ${args.a} ${args.b}`.text() + return result.trim() + }, +}) +``` + +这里我们使用 [`Bun.$`](https://bun.com/docs/runtime/shell) 工具函数来运行 Python 脚本。 diff --git a/packages/web/src/content/docs/zh-cn/ecosystem.mdx b/packages/web/src/content/docs/zh-cn/ecosystem.mdx new file mode 100644 index 0000000000000000000000000000000000000000..aaa6a3ede4130fa46d009dfb55fbbf63b79a96fc --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/ecosystem.mdx @@ -0,0 +1,78 @@ +--- +title: 生态系统 +description: 基于 OpenCode 构建的项目与集成。 +--- + +基于 OpenCode 构建的社区项目合集。 + +:::note +想将您的 OpenCode 相关项目添加到此列表中?欢迎提交 PR。 +::: + +您还可以查看 [awesome-opencode](https://github.com/awesome-opencode/awesome-opencode) 和 [opencode.cafe](https://opencode.cafe),这是一个聚合生态系统与社区资源的社区。 + +--- + +## 插件 + +| 名称 | 描述 | +| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [opencode-daytona](https://github.com/daytonaio/daytona/tree/main/libs/opencode-plugin) | 在隔离的 Daytona 沙箱中自动运行 OpenCode 会话,支持 git 同步和实时预览 | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | 自动注入 Helicone 会话头信息,用于请求分组 | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | 通过查找工具自动将 TypeScript/Svelte 类型注入到文件读取中 | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | 使用您的 ChatGPT Plus/Pro 订阅替代 API 额度 | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | 使用您现有的 Gemini 套餐替代 API 计费 | +| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | 使用 Antigravity 的免费模型替代 API 计费 | +| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | 多分支开发容器隔离,支持浅克隆和自动分配端口 | +| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth 插件,支持 Google 搜索及更强健的 API 处理 | +| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | 通过修剪过时的工具输出来优化 Token 使用 | +| [opencode-vibeguard](https://github.com/inkdust2021/opencode-vibeguard) | 在调用 LLM 之前将机密/PII 替换为 VibeGuard 风格的占位符;并在本地恢复 | +| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | 为受支持的提供商添加原生网页搜索支持,采用 Google grounded 风格 | +| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | 使 AI 代理能够在 PTY 中运行后台进程,并向其发送交互式输入 | +| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | 非交互式 shell 命令指令——防止依赖 TTY 的操作导致挂起 | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | 使用 Wakatime 追踪 OpenCode 的使用情况 | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | 清理 LLM 生成的 Markdown 表格 | +| [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | 通过 Morph 提供 Fast Apply 编辑、WarpGrep 代码搜索和上下文压缩 | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | 后台代理、预构建的 LSP/AST/MCP 工具、精选代理,兼容 Claude Code | +| [opencode-notificator](https://github.com/panta82/opencode-notificator) | OpenCode 会话的桌面通知和声音提醒 | +| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | 针对权限请求、任务完成和错误事件的桌面通知与声音提醒 | +| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | 基于 OpenCode 上下文的 AI 驱动自动 Zellij 会话命名 | +| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | 允许 OpenCode 代理通过技能发现和注入按需延迟加载提示词 | +| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | 使用 Supermemory 实现跨会话的持久记忆 | +| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | 支持可视化标注和私有/离线分享的交互式计划审查 | +| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | 将 OpenCode /commands 扩展为具有精细流程控制的强大编排系统 | +| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | 使用 cron 语法通过 launchd (Mac) 或 systemd (Linux) 调度周期性任务 | +| [micode](https://github.com/vtemian/micode) | 结构化的头脑风暴 → 计划 → 实现工作流,支持会话连续性 | +| [octto](https://github.com/vtemian/octto) | 用于 AI 头脑风暴的交互式浏览器 UI,支持多问题表单 | +| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code 风格的后台代理,支持异步委托和上下文持久化 | +| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | OpenCode 的原生操作系统通知——随时了解任务完成情况 | +| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | 捆绑式多代理编排套件——16 个组件,一次安装 | +| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | OpenCode 的零摩擦 git worktree 管理 | +| [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | 使用 Sentry AI Monitoring 追踪和调试您的 AI 代理 | + +--- + +## 项目 + +| 名称 | 描述 | +| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | +| [kimaki](https://github.com/remorses/kimaki) | 用于控制 OpenCode 会话的 Discord 机器人,基于 SDK 构建 | +| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim 插件,提供编辑器感知的提示词,基于 API 构建 | +| [portal](https://github.com/hosenur/portal) | 通过 Tailscale/VPN 使用的移动优先 OpenCode Web UI | +| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | 用于构建 OpenCode 插件的模板 | +| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | OpenCode 的 Neovim 前端——基于终端的 AI 编码代理 | +| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK 提供商,用于通过 @opencode-ai/sdk 使用 OpenCode | +| [OpenChamber](https://github.com/btriapitsyn/openchamber) | OpenCode 的 Web / 桌面应用和 VS Code 扩展 | +| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | 将 OpenCode 嵌入 Obsidian UI 的 Obsidian 插件 | +| [OpenWork](https://github.com/different-ai/openwork) | Claude Cowork 的开源替代方案,由 OpenCode 驱动 | +| [ocx](https://github.com/kdcokenny/ocx) | OpenCode 扩展管理器,支持可移植的隔离配置 | +| [CodeNomad](https://github.com/NeuralNomadsAI/CodeNomad) | OpenCode 的桌面、Web、移动和远程客户端应用 | + +--- + +## 代理 + +| 名称 | 描述 | +| ----------------------------------------------------------------- | ---------------------------------------- | +| [Agentic](https://github.com/Cluster444/agentic) | 用于结构化开发的模块化 AI 代理和命令 | +| [opencode-agents](https://github.com/darrenhinde/opencode-agents) | 用于增强工作流的配置、提示词、代理和插件 | diff --git a/packages/web/src/content/docs/zh-cn/enterprise.mdx b/packages/web/src/content/docs/zh-cn/enterprise.mdx new file mode 100644 index 0000000000000000000000000000000000000000..7a70323c73022b99ed0f50faa876442fe3fb351b --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/enterprise.mdx @@ -0,0 +1,165 @@ +--- +title: 企业版 +description: 在您的组织中安全地使用 OpenCode。 +--- + +import config from "../../../../config.mjs" +export const email = `mailto:${config.email}` + +OpenCode 企业版面向希望确保代码和数据始终留在自有基础设施内的组织。它通过集中式配置与您的 SSO 和内部 AI 网关集成来实现这一目标。 + +:::note +OpenCode 不会存储您的任何代码或上下文数据。 +::: + +开始使用 OpenCode 企业版: + +1. 在团队内部进行试用。 +2. **联系我们**,讨论定价和实施方案。 + +--- + +## 试用 + +OpenCode 是开源的,不会存储您的任何代码或上下文数据,因此您的开发人员可以直接[开始使用](/docs/)并进行试用。 + +--- + +### 数据处理 + +**OpenCode 不会存储您的代码或上下文数据。** 所有处理均在本地完成,或通过直接 API 调用发送至您的 AI 提供商。 + +这意味着,只要您使用的是信任的提供商或内部 AI 网关,就可以安全地使用 OpenCode。 + +唯一需要注意的是可选的 `/share` 功能。 + +--- + +#### 分享对话 + +如果用户启用了 `/share` 功能,对话及其关联数据将被发送到我们用于在 opencode.ai 上托管共享页面的服务。 + +数据目前通过我们 CDN 的边缘网络提供服务,并缓存在靠近用户的边缘节点上。 + +我们建议您在试用期间禁用此功能。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled" +} +``` + +[了解更多关于分享的信息](/docs/share)。 + +--- + +### 代码所有权 + +**您拥有 OpenCode 生成的所有代码。** 不存在任何许可限制或所有权声明。 + +--- + +## 定价 + +OpenCode 企业版采用按席位定价模型。如果您拥有自己的 LLM 网关,我们不会对使用的 Token 收取费用。有关定价和实施方案的更多详情,请 **联系我们** 。 + +--- + +## 部署 + +完成试用并准备好在组织中使用 OpenCode 后,您可以 **联系我们** ,讨论定价和实施方案。 + +--- + +### 集中式配置 + +我们可以为您的整个组织设置 OpenCode 的统一集中式配置。 + +该集中式配置可与您的 SSO 提供商集成,确保所有用户仅访问您的内部 AI 网关。 + +--- + +### SSO 集成 + +通过集中式配置,OpenCode 可以与您组织的 SSO 提供商集成进行身份验证。 + +这使得 OpenCode 能够通过您现有的身份管理系统获取内部 AI 网关的凭据。 + +--- + +### 内部 AI 网关 + +通过集中式配置,OpenCode 还可以被配置为仅使用您的内部 AI 网关。 + +您还可以禁用所有其他 AI 提供商,确保所有请求都经过组织批准的基础设施。 + +--- + +### 自托管 + +虽然我们建议禁用共享页面以确保您的数据始终不会离开组织,但我们也可以帮助您在自己的基础设施上自行托管这些页面。 + +此功能目前已列入我们的路线图。如果您感兴趣,请 **告诉我们** 。 + +--- + +## 常见问题 + +

+什么是 OpenCode 企业版? + +OpenCode 企业版面向希望确保代码和数据始终留在自有基础设施内的组织。它通过集中式配置与您的 SSO 和内部 AI 网关集成来实现这一目标。 + +
+ +
+如何开始使用 OpenCode 企业版? + +只需在团队内部开始试用即可。OpenCode 默认不存储您的代码或上下文数据,因此可以轻松上手。 + +然后 **联系我们** ,讨论定价和实施方案。 + +
+ +
+企业版定价如何运作? + +我们提供按席位的企业版定价。如果您拥有自己的 LLM 网关,我们不会对使用的 Token 收取费用。如需了解更多详情,请 **联系我们** ,获取根据您组织需求定制的报价。 + +
+ +
+我的数据在 OpenCode 企业版中是否安全? + +是的。OpenCode 不会存储您的代码或上下文数据。所有处理均在本地完成,或通过直接 API 调用发送至您的 AI 提供商。通过集中式配置和 SSO 集成,您的数据将安全地保留在组织的基础设施内。 + +
+ +
+我们可以使用自己的私有 NPM 注册表吗? + +OpenCode 通过 Bun 原生的 `.npmrc` 文件支持来支持私有 npm 注册表。如果您的组织使用私有注册表(例如 JFrog Artifactory、Nexus 或类似产品),请确保开发人员在运行 OpenCode 之前已完成身份验证。 + +要设置私有注册表的身份验证: + +```bash +npm login --registry=https://your-company.jfrog.io/api/npm/npm-virtual/ +``` + +这会创建包含身份验证信息的 `~/.npmrc` 文件。OpenCode 会自动识别并使用它。 + +:::caution +在运行 OpenCode 之前,您必须先登录私有注册表。 +::: + +或者,您也可以手动配置 `.npmrc` 文件: + +```bash title="~/.npmrc" +registry=https://your-company.jfrog.io/api/npm/npm-virtual/ +//your-company.jfrog.io/api/npm/npm-virtual/:_authToken=${NPM_AUTH_TOKEN} +``` + +开发人员必须在运行 OpenCode 之前登录私有注册表,以确保能够从您的企业注册表安装软件包。 + +
diff --git a/packages/web/src/content/docs/zh-cn/formatters.mdx b/packages/web/src/content/docs/zh-cn/formatters.mdx new file mode 100644 index 0000000000000000000000000000000000000000..1f4035fda6b55c55f297b4ecd3ad0a559cda9b4c --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/formatters.mdx @@ -0,0 +1,132 @@ +--- +title: 格式化工具 +description: OpenCode 使用特定语言的格式化工具。 +--- + +OpenCode 会在文件写入或编辑后,自动使用特定语言的格式化工具对其进行格式化。这确保了生成的代码遵循你项目的代码风格。 + +--- + +## 内置格式化工具 + +OpenCode 内置了多种适用于主流语言和框架的格式化工具。下表列出了各格式化工具、支持的文件扩展名以及所需的命令或配置选项。 + +| 格式化工具 | 扩展名 | 要求 | +| -------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| air | .R | `air` 命令可用 | +| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml 及[更多](https://biomejs.dev/) | `biome.json(c)` 配置文件 | +| cargofmt | .rs | `cargo fmt` 命令可用 | +| clang-format | .c, .cpp, .h, .hpp, .ino 及[更多](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` 配置文件 | +| cljfmt | .clj, .cljs, .cljc, .edn | `cljfmt` 命令可用 | +| dart | .dart | `dart` 命令可用 | +| dfmt | .d | `dfmt` 命令可用 | +| gleam | .gleam | `gleam` 命令可用 | +| gofmt | .go | `gofmt` 命令可用 | +| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` 命令可用 | +| ktlint | .kt, .kts | `ktlint` 命令可用 | +| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` 命令可用 | +| nixfmt | .nix | `nixfmt` 命令可用 | +| ocamlformat | .ml, .mli | `ocamlformat` 命令可用且存在 `.ocamlformat` 配置文件 | +| ormolu | .hs | `ormolu` 命令可用 | +| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `package.json` 中有 `oxfmt` 依赖,且设置了[实验性环境变量标志](/docs/cli/#experimental) | +| pint | .php | `composer.json` 中有 `laravel/pint` 依赖 | +| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml 及[更多](https://prettier.io/docs/en/index.html) | `package.json` 中有 `prettier` 依赖 | +| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` 命令可用 | +| ruff | .py, .pyi | `ruff` 命令可用且有相应配置 | +| rustfmt | .rs | `rustfmt` 命令可用 | +| shfmt | .sh, .bash | `shfmt` 命令可用 | +| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` 命令可用 | +| terraform | .tf, .tfvars | `terraform` 命令可用 | +| uv | .py, .pyi | `uv` 命令可用 | +| zig | .zig, .zon | `zig` 命令可用 | + +因此,如果你的项目 `package.json` 中包含 `prettier`,OpenCode 会自动使用它进行格式化。 + +--- + +## 工作原理 + +当 OpenCode 写入或编辑文件时,它会: + +1. 根据所有已启用的格式化工具检查文件扩展名。 +2. 对文件运行相应的格式化命令。 +3. 自动应用格式化更改。 + +整个过程在后台完成,无需任何手动操作即可保持代码风格的一致性。 + +--- + +## 配置 + +你可以通过 OpenCode 配置中的 `formatter` 部分自定义格式化工具。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": {} +} +``` + +每个格式化工具的配置支持以下属性: + +| 属性 | 类型 | 描述 | +| ------------- | -------- | ------------------------------ | +| `disabled` | boolean | 设为 `true` 可禁用该格式化工具 | +| `command` | string[] | 执行格式化的命令 | +| `environment` | object | 运行格式化工具时设置的环境变量 | +| `extensions` | string[] | 该格式化工具处理的文件扩展名 | + +下面来看一些示例。 + +--- + +### 禁用格式化工具 + +要全局禁用**所有**格式化工具,将 `formatter` 设为 `false`: + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": false +} +``` + +要禁用**特定**格式化工具,将 `disabled` 设为 `true`: + +```json title="opencode.json" {5} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "disabled": true + } + } +} +``` + +--- + +### 自定义格式化工具 + +你可以通过指定命令、环境变量和文件扩展名来覆盖内置格式化工具或添加新的格式化工具: + +```json title="opencode.json" {4-14} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "command": ["npx", "prettier", "--write", "$FILE"], + "environment": { + "NODE_ENV": "development" + }, + "extensions": [".js", ".ts", ".jsx", ".tsx"] + }, + "custom-markdown-formatter": { + "command": ["deno", "fmt", "$FILE"], + "extensions": [".md"] + } + } +} +``` + +命令中的 **`$FILE` 占位符**会被替换为待格式化文件的路径。 diff --git a/packages/web/src/content/docs/zh-cn/github.mdx b/packages/web/src/content/docs/zh-cn/github.mdx new file mode 100644 index 0000000000000000000000000000000000000000..01847f193820aca05389284a14ef8eed45cef332 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/github.mdx @@ -0,0 +1,321 @@ +--- +title: GitHub +description: 在 GitHub Issue 和 Pull Request 中使用 OpenCode。 +--- + +OpenCode 可以与你的 GitHub 工作流集成。在评论中提及 `/opencode` 或 `/oc`,OpenCode 就会在你的 GitHub Actions 运行器中执行任务。 + +--- + +## 功能特性 + +- **问题分类**:让 OpenCode 调查某个 Issue 并为你做出解释。 +- **修复与实现**:让 OpenCode 修复 Issue 或实现某个功能。它会在新分支中工作,并提交包含所有变更的 PR。 +- **安全可靠**:OpenCode 在你自己的 GitHub 运行器中运行。 + +--- + +## 安装 + +在一个位于 GitHub 仓库中的项目里运行以下命令: + +```bash +opencode github install +``` + +该命令会引导你完成 GitHub App 的安装、工作流的创建以及密钥的配置。 + +--- + +### 手动设置 + +你也可以手动进行设置。 + +1. **安装 GitHub App** + + 前往 [**github.com/apps/opencode-agent**](https://github.com/apps/opencode-agent),确保已在目标仓库中安装该应用。 + +2. **添加工作流** + + 将以下工作流文件添加到仓库的 `.github/workflows/opencode.yml` 中。请确保在 `env` 中设置合适的 `model` 及所需的 API 密钥。 + + ```yml title=".github/workflows/opencode.yml" {24,26} + name: opencode + + on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + + jobs: + opencode: + if: | + contains(github.event.comment.body, '/oc') || + contains(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run OpenCode + uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + # share: true + # github_token: xxxx + ``` + +3. **将 API 密钥存储到 Secrets 中** + + 在你的组织或项目的 **Settings** 中,展开左侧的 **Secrets and variables**,然后选择 **Actions**,添加所需的 API 密钥。 + +--- + +## 配置 + +- `model`:OpenCode 使用的模型,格式为 `provider/model`。此项为**必填**。 +- `agent`:要使用的代理,必须是主代理。如果未找到,则回退到配置中的 `default_agent`,若仍未找到则使用 `"build"`。 +- `share`:是否共享 OpenCode 会话。对于公开仓库,默认为 **true**。 +- `prompt`:可选的自定义提示词,用于覆盖默认行为。可通过此项自定义 OpenCode 处理请求的方式。 +- `token`:可选的 GitHub 访问 Token,用于执行创建评论、提交变更和创建 Pull Request 等操作。默认情况下,OpenCode 使用 OpenCode GitHub App 的安装访问 Token,因此提交、评论和 Pull Request 会显示为来自该应用。 + + 你也可以使用 GitHub Action 运行器内置的 [`GITHUB_TOKEN`](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token),而无需安装 OpenCode GitHub App。只需确保在工作流中授予所需的权限: + + ```yaml + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + ``` + + 如果你愿意,也可以使用[个人访问令牌](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)(PAT)。 + +--- + +## 支持的事件 + +OpenCode 可以由以下 GitHub 事件触发: + +| 事件类型 | 触发方式 | 详情 | +| ----------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------- | +| `issue_comment` | 在 Issue 或 PR 上发表评论 | 在评论中提及 `/opencode` 或 `/oc`。OpenCode 会读取上下文,并可创建分支、提交 PR 或回复。 | +| `pull_request_review_comment` | 在 PR 中对特定代码行发表评论 | 在代码审查时提及 `/opencode` 或 `/oc`。OpenCode 会接收文件路径、行号和 diff 上下文。 | +| `issues` | Issue 被创建或编辑 | 在 Issue 创建或修改时自动触发 OpenCode。需要提供 `prompt` 输入。 | +| `pull_request` | PR 被创建或更新 | 在 PR 被打开、同步或重新打开时自动触发 OpenCode。适用于自动化审查场景。 | +| `schedule` | 基于 Cron 的定时任务 | 按计划运行 OpenCode。需要提供 `prompt` 输入。输出会写入日志和 PR(没有 Issue 可供评论)。 | +| `workflow_dispatch` | 从 GitHub UI 手动触发 | 通过 Actions 选项卡按需触发 OpenCode。需要提供 `prompt` 输入。输出会写入日志和 PR。 | + +### 定时任务示例 + +按计划运行 OpenCode 以执行自动化任务: + +```yaml title=".github/workflows/opencode-scheduled.yml" +name: Scheduled OpenCode Task + +on: + schedule: + - cron: "0 9 * * 1" # Every Monday at 9am UTC + +jobs: + opencode: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run OpenCode + uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review the codebase for any TODO comments and create a summary. + If you find issues worth addressing, open an issue to track them. +``` + +对于定时事件,`prompt` 输入为**必填**,因为没有评论可供提取指令。定时工作流在运行时没有用户上下文来进行权限检查,因此如果你希望 OpenCode 创建分支或 PR,工作流必须授予 `contents: write` 和 `pull-requests: write` 权限。 + +--- + +### Pull Request 示例 + +在 PR 被创建或更新时自动进行审查: + +```yaml title=".github/workflows/opencode-review.yml" +name: opencode-review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + review: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + model: anthropic/claude-sonnet-4-20250514 + use_github_token: true + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements +``` + +对于 `pull_request` 事件,如果未提供 `prompt`,OpenCode 将默认对该 Pull Request 进行审查。 + +--- + +### Issue 分类示例 + +自动分类新建的 Issue。以下示例会过滤掉注册不满 30 天的账户以减少垃圾信息: + +```yaml title=".github/workflows/opencode-triage.yml" +name: Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Check account age + id: check + uses: actions/github-script@v7 + with: + script: | + const user = await github.rest.users.getByUsername({ + username: context.payload.issue.user.login + }); + const created = new Date(user.data.created_at); + const days = (Date.now() - created) / (1000 * 60 * 60 * 24); + return days >= 30; + result-encoding: string + + - uses: actions/checkout@v6 + if: steps.check.outputs.result == 'true' + with: + persist-credentials: false + + - uses: anomalyco/opencode/github@latest + if: steps.check.outputs.result == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review this issue. If there's a clear fix or relevant docs: + - Provide documentation links + - Add error handling guidance for code examples + Otherwise, do not comment. +``` + +对于 `issues` 事件,`prompt` 输入为**必填**,因为没有评论可供提取指令。 + +--- + +## 自定义提示词 + +覆盖默认提示词,以便为你的工作流自定义 OpenCode 的行为。 + +```yaml title=".github/workflows/opencode.yml" +- uses: anomalyco/opencode/github@latest + with: + model: anthropic/claude-sonnet-4-5 + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements +``` + +这对于在项目中实施特定的审查标准、编码规范或关注重点非常有用。 + +--- + +## 示例 + +以下是在 GitHub 中使用 OpenCode 的一些示例。 + +- **解释 Issue** + + 在 GitHub Issue 中添加以下评论: + + ``` + /opencode explain this issue + ``` + + OpenCode 会阅读整个讨论串(包括所有评论),并回复一份清晰的解释。 + +- **修复 Issue** + + 在 GitHub Issue 中输入: + + ``` + /opencode fix this + ``` + + OpenCode 会创建一个新分支,实现变更,并提交一个包含所有修改的 PR。 + +- **审查 PR 并进行修改** + + 在 GitHub PR 上留下以下评论: + + ``` + Delete the attachment from S3 when the note is removed /oc + ``` + + OpenCode 会实现所请求的变更并将其提交到同一个 PR 中。 + +- **审查特定代码行** + + 在 PR 的 "Files" 选项卡中直接对代码行留下评论。OpenCode 会自动检测文件、行号和 diff 上下文,从而提供精准的响应。 + + ``` + [Comment on specific lines in Files tab] + /oc add error handling here + ``` + + 当你对特定代码行发表评论时,OpenCode 会接收到: + - 正在审查的具体文件 + - 特定的代码行 + - 周围的 diff 上下文 + - 行号信息 + + 这样你就可以提出更有针对性的请求,而无需手动指定文件路径或行号。 diff --git a/packages/web/src/content/docs/zh-cn/gitlab.mdx b/packages/web/src/content/docs/zh-cn/gitlab.mdx new file mode 100644 index 0000000000000000000000000000000000000000..c1ffa0be614c73c640b1af79c930b3993bdcc9fc --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/gitlab.mdx @@ -0,0 +1,194 @@ +--- +title: GitLab +description: 在 GitLab issue 和合并请求中使用 OpenCode。 +--- + +OpenCode 通过 GitLab CI/CD 流水线或 GitLab Duo 与你的 GitLab 工作流集成。 + +在这两种情况下,OpenCode 都将在你的 GitLab Runner 上运行。 + +--- + +## GitLab CI + +OpenCode 可以在常规的 GitLab 流水线中运行。你可以将其作为 [CI 组件](https://docs.gitlab.com/ee/ci/components/) 集成到流水线中。 + +这里我们使用的是社区创建的 OpenCode CI/CD 组件 — [nagyv/gitlab-opencode](https://gitlab.com/nagyv/gitlab-opencode)。 + +--- + +### 功能特性 + +- **按任务自定义配置**:使用自定义配置目录来配置 OpenCode,例如 `./config/#custom-directory`,以便为每次 OpenCode 调用启用或禁用特定功能。 +- **最小化配置**:CI 组件会在后台完成 OpenCode 的设置,你只需创建 OpenCode 配置和初始提示词即可。 +- **灵活可定制**:CI 组件支持多种输入参数来自定义其行为。 + +--- + +### 设置 + +1. 将你的 OpenCode 身份验证 JSON 作为文件类型的 CI 环境变量存储在 **Settings** > **CI/CD** > **Variables** 下。请确保将其标记为 "Masked and hidden"。 +2. 将以下内容添加到你的 `.gitlab-ci.yml` 文件中。 + + ```yaml title=".gitlab-ci.yml" + include: + - component: $CI_SERVER_FQDN/nagyv/gitlab-opencode/opencode@2 + inputs: + config_dir: ${CI_PROJECT_DIR}/opencode-config + auth_json: $OPENCODE_AUTH_JSON # The variable name for your OpenCode authentication JSON + command: optional-custom-command + message: "Your prompt here" + ``` + +有关更多输入参数和使用场景,请[查看该组件的文档](https://gitlab.com/explore/catalog/nagyv/gitlab-opencode)。 + +--- + +## GitLab Duo + +OpenCode 与你的 GitLab 工作流集成。 +在评论中提及 `@opencode`,OpenCode 将在你的 GitLab CI 流水线中执行任务。 + +--- + +### 功能特性 + +- **问题分类**:让 OpenCode 调查某个 issue 并为你解释。 +- **修复与实现**:让 OpenCode 修复 issue 或实现某个功能。它会创建一个新分支,并提交包含更改的合并请求。 +- **安全可靠**:OpenCode 在你的 GitLab Runner 上运行。 + +--- + +### 设置 + +OpenCode 在你的 GitLab CI/CD 流水线中运行,以下是设置所需的步骤: + +:::tip +请查看 [**GitLab 文档**](https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/) 获取最新说明。 +::: + +1. 配置你的 GitLab 环境 +2. 设置 CI/CD +3. 获取 AI 模型提供商的 API 密钥 +4. 创建服务账户 +5. 配置 CI/CD 变量 +6. 创建流程配置文件,以下是一个示例: + +
+ + Flow configuration + + ```yaml + image: node:22-slim + commands: + - echo "Installing opencode" + - npm install --global opencode-ai + - echo "Installing glab" + - export GITLAB_TOKEN=$GITLAB_TOKEN_OPENCODE + - apt-get update --quiet && apt-get install --yes curl wget gpg git && rm --recursive --force /var/lib/apt/lists/* + - curl --silent --show-error --location "https://raw.githubusercontent.com/upciti/wakemeops/main/assets/install_repository" | bash + - apt-get install --yes glab + - echo "Configuring glab" + - echo $GITLAB_HOST + - echo "Creating OpenCode auth configuration" + - mkdir --parents ~/.local/share/opencode + - | + cat > ~/.local/share/opencode/auth.json << EOF + { + "anthropic": { + "type": "api", + "key": "$ANTHROPIC_API_KEY" + } + } + EOF + - echo "Configuring git" + - git config --global user.email "opencode@gitlab.com" + - git config --global user.name "OpenCode" + - echo "Testing glab" + - glab issue list + - echo "Running OpenCode" + - | + opencode run " + You are an AI assistant helping with GitLab operations. + + Context: $AI_FLOW_CONTEXT + Task: $AI_FLOW_INPUT + Event: $AI_FLOW_EVENT + + Please execute the requested task using the available GitLab tools. + Be thorough in your analysis and provide clear explanations. + + + Please use the glab CLI to access data from GitLab. The glab CLI has already been authenticated. You can run the corresponding commands. + + If you are asked to summarize an MR or issue or asked to provide more information then please post back a note to the MR/Issue so that the user can see it. + You don't need to commit or push up changes, those will be done automatically based on the file changes you make. + + " + - git checkout --branch $CI_WORKLOAD_REF origin/$CI_WORKLOAD_REF + - echo "Checking for git changes and pushing if any exist" + - | + if ! git diff --quiet || ! git diff --cached --quiet || [ --not --zero "$(git ls-files --others --exclude-standard)" ]; then + echo "Git changes detected, adding and pushing..." + git add . + if git diff --cached --quiet; then + echo "No staged changes to commit" + else + echo "Committing changes to branch: $CI_WORKLOAD_REF" + git commit --message "Codex changes" + echo "Pushing changes up to $CI_WORKLOAD_REF" + git push https://gitlab-ci-token:$GITLAB_TOKEN@$GITLAB_HOST/gl-demo-ultimate-dev-ai-epic-17570/test-java-project.git $CI_WORKLOAD_REF + echo "Changes successfully pushed" + fi + else + echo "No git changes detected, skipping push" + fi + variables: + - ANTHROPIC_API_KEY + - GITLAB_TOKEN_OPENCODE + - GITLAB_HOST + ``` + +
+ +详细说明请参考 [GitLab CLI agents 文档](https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/)。 + +--- + +### 示例 + +以下是在 GitLab 中使用 OpenCode 的一些示例。 + +:::tip +你可以配置使用不同于 `@opencode` 的触发词。 +::: + +- **解释 issue** + + 在 GitLab issue 中添加以下评论。 + + ``` + @opencode explain this issue + ``` + + OpenCode 会阅读该 issue 并回复清晰的解释。 + +- **修复 issue** + + 在 GitLab issue 中输入: + + ``` + @opencode fix this + ``` + + OpenCode 会创建一个新分支,实现更改,并提交包含更改的合并请求。 + +- **审查合并请求** + + 在 GitLab 合并请求中留下以下评论。 + + ``` + @opencode review this merge request + ``` + + OpenCode 会审查合并请求并提供反馈。 diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx new file mode 100644 index 0000000000000000000000000000000000000000..7123ffcf8a0d40f955f09a60920bacbc9d67667f --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -0,0 +1,363 @@ +--- +title: Go +description: 低成本的开源编程模型订阅服务。 +--- + +import config from "../../../../config.mjs" +export const console = config.console +export const email = `mailto:${config.email}` + +OpenCode Go 是一项**每月 10 美元的低成本订阅服务**,让你能够稳定地访问流行的开源编程模型。 + +Go 的工作方式与 OpenCode 中的任何其他提供商(provider)一样。订阅 OpenCode Go 后你将获得 API 密钥。它是 **完全可选** 的,并非使用 OpenCode 所必需的条件。 + +它主要面向国际用户,并提供稳定的全球访问。 + +--- + +## 背景 + +开源模型现在变得非常强大。在编程任务中,它们的性能已接近专有模型。由于许多提供商都可以提供具有竞争力的服务,它们通常要便宜得多。 + +然而,获得可靠、低延迟的访问可能很困难。各提供商在质量和可用性方面参差不齐。 + +:::tip +我们测试了一组经过精选且与 OpenCode 配合良好的模型和提供商。 +::: + +为了解决这个问题,我们做了以下几件事: + +1. 我们测试了一组精选的开源模型,并与他们的团队探讨了如何以最佳方式运行它们。 +2. 随后我们与一些提供商合作,以确保正确提供这些服务。 +3. 最后,我们对模型和提供商的组合进行了基准测试(benchmark),得出了一份我们乐于推荐的列表。 + +OpenCode Go 让你能够以**每月 10 美元**的价格访问这些模型。 + +--- + +## 工作原理 + +OpenCode Go 的工作方式与 OpenCode 中的其他提供商一样。 + +1. 登录 **OpenCode Zen**,订阅 Go,然后复制你的 API 密钥。 +2. 在 TUI 中运行 `/connect` 命令,选择 `OpenCode Go`,然后粘贴你的 API 密钥。 +3. 在 TUI 中运行 `/models` 以查看通过 Go 可用的模型列表。 + +:::note +每个工作空间只能有一名成员订阅 OpenCode Go。 +::: + +当前支持的模型列表包括: + +- **Grok 4.6** +- **GLM-5.3-Flash** +- **GLM-5.3** +- **GLM-5.2** +- **GLM-5.1** +- **GPT 5.6 Luna** +- **Kimi K3** +- **Kimi K2.7 Code** +- **Kimi K2.6** +- **LongCat-2.0** +- **MiMo-V2.5** +- **MiMo-V2.5-Pro** +- **MiniMax M3** +- **MiniMax M2.7** +- **Muse Spark 1.3 Contributor** ([仅限部分地区](https://ai.developer.meta.com/legal/geographic-use-policy)) +- **Muse Spark 1.2 Contributor** ([仅限部分地区](https://ai.developer.meta.com/legal/geographic-use-policy)) +- **Qwen3.8 Max** +- **Qwen3.8 Flash** +- **Qwen3.7 Max** +- **Qwen3.7 Plus** +- **Qwen3.6 Plus** +- **DeepSeek V4.1 Flash** +- **DeepSeek V4 Pro** +- **DeepSeek V4 Flash** +- **DeepSeek V4 Flash Vision Exp** +- **Hy4 preview** +- **Hy3** + +随着我们进行测试和添加新模型,该列表可能会发生变化。 + +--- + +## 可以在哪里使用? + +OpenCode Go 适用于 [OpenCode](https://opencode.ai) 以及其他会产生类似请求的编程 Agent。 + +我们会监控流量,以识别影响其他用户体验的滥用行为。 + +你的客户端应当: + +1. 发送典型的编程 Agent 流量。 +2. 使用自身专属的 user agent 标识(例如 `my-coding-agent/1.0`),而不是通用的 SDK 或 HTTP 库名称。 +3. 为每段对话在 `x-opencode-session` 请求头中发送稳定的会话 ID,以便我们优化路由和提示词缓存。 + +### 已验证的客户端 + +除 OpenCode 外,以下客户端已通过验证,能够正常使用 OpenCode Go。但我们无法保证它们未来仍能正常使用。 + +| 客户端 | 会话支持 | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Hermes** | 包含 [PR #101864](https://github.com/NousResearch/hermes-agent/pull/101864) 的构建版本会在主要和辅助 OpenCode 请求中发送该请求头。此修复在 v0.21.0 发布后才合并,因此 v0.21.0 本身并不包含该修复。 | +| **Claude Code** | Go 能识别其原生会话请求头,无需额外封装来添加自定义请求头。 | +| **Codex** | Go 能识别其原生会话请求头。某些版本和代理配置仍会遗漏该请求头;转发请求时请保留会话请求头。 | +| **ZCode** | Go 能识别其原生会话请求头。我们[请求支持 `x-opencode-session` 的 issue](https://github.com/zai-org/feedback/issues/492) 仍处于开放状态,但已不再需要发送这一特定请求头。 | +| **Pi** | 当前构建版本会为 OpenCode 发送会话信息。请更新旧版安装。 | +| **jcode** | 请更新至 **v0.81.6 或更高版本**,其中包含[会话请求头修复](https://github.com/1jehuang/jcode/issues/1167)。 | +| **Kilo Code CLI** | 包含 [PR #13752](https://github.com/Kilo-Org/kilocode/pull/13752) 的构建版本恢复了 OpenCode 会话请求头。此修复仅适用于 CLI,不适用于 VS Code 扩展。参见 [issue #13723](https://github.com/Kilo-Org/kilocode/issues/13723)。 | + +### 已知存在问题的客户端 + +在我们调查的版本中,以下客户端缺少会话支持,或支持不完整。相关报告链接可用于跟踪修复进展和临时解决方案。 + +| 客户端 | 状态与跟踪 | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **DeepSeek Harness** | 某些模型调用路径会传递会话信息,但其他路径中缺失。我们能识别其原生请求头;剩余工作是在所有适配器中发送该请求头。参见[讨论 #5495](https://github.com/deepseek-ai/deepseek-harness/discussions/5495)。 | +| **GitHub Copilot Chat** | [VS Code issue #334186](https://github.com/microsoft/vscode/issues/334186) 已提出自动发送会话请求头的支持请求。 | +| **Kimi Code** | [issue #3506](https://github.com/MoonshotAI/kimi-code/issues/3506) 已提出自动发送会话请求头的支持请求。 | +| **MiMo Code** | [Issue #2317](https://github.com/XiaomiMiMo/MiMo-Code/issues/2317) 已有拟议修复 [PR #2327](https://github.com/XiaomiMiMo/MiMo-Code/pull/2327),但尚未合并。 | + +## 使用限制 + +使用限制以每月美元金额定义。下表列出了每个模型的每月限制和 token 成本。 + +每个模型都有以下使用限制:5 小时 — 每月限制的 20%;每周 — 50%;每月 — 100%。 + +例如,如果某个模型的每月限制为 $60,你最多可以使用: + +- **5 小时限制** — $12 的使用额度 +- **每周限制** — $30 的使用额度 +- **每月限制** — $60 的使用额度 + +Token 价格按每 1M tokens 列示。 + +| 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 | 每月限制 | +| --------------------------------------- | ------ | ------ | --------- | -------- | ------------------------------------------------------- | +| GLM-5.3-Flash | $0.15 | $0.50 | $0.03 | - | **$60** | +| GLM-5.3 | $1.40 | $4.40 | $0.26 | - | **$15** | +| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | **$60** | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | **$60** | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | **$15** | +| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | **$60** | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | **$60** | +| LongCat-2.0 | $0.30 | $1.20 | $0.006 | - | **$60** | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | **$60** | +| MiMo V2.5 Pro | $0.435 | $0.87 | $0.003625 | - | **$15** | +| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | **$60** | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** | +| Muse Spark 1.3 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** | +| Muse Spark 1.2 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** | +| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | **$15** | +| Qwen3.8 Flash | $0.15 | $0.47 | $0.016 | $0.20 | **$30** | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | **$30** | +| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 | **$60** | +| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 | **$60** | +| Qwen3.6 Plus (≤ 256K tokens) | $0.50 | $3.00 | $0.05 | $0.625 | **$60** | +| Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | **$60** | +| DeepSeek V4.1 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | ~~$15~~ **$60**
4x · 9 月 20 日结束 | +| DeepSeek V4.1 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | ~~$15~~ **$60**
4x · 9 月 20 日结束 | +| DeepSeek V4 Pro (Off-Peak) | $0.66 | $1.98 | $0.022 | - | **$15** | +| DeepSeek V4 Pro (Peak) | $1.32 | $3.96 | $0.044 | - | **$15** | +| DeepSeek V4 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$30** | +| DeepSeek V4 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$30** | +| DeepSeek V4 Flash Vision Exp (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$15** | +| DeepSeek V4 Flash Vision Exp (Peak) | $0.30 | $1.20 | $0.006 | - | **$15** | +| Hy4 preview | $0.834 | $2.501 | $0.042 | - | **$30** | +| Hy3 | $0.14 | $0.58 | $0.035 | - | **$60** | +| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** | +| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** | +| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$15** | +| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | **$15** | + +**DeepSeek V4.1 Flash / V4 Pro / V4 Flash / V4 Flash Vision Exp:** Peak 时段为周一至周五的 01:00-04:00 和 06:00-10:00 UTC;其他所有时段(包括周末)均为 Off-Peak。[了解更多](https://api-docs.deepseek.com/quick_start/pricing/)。 + +**DeepSeek V4 Flash Vision Exp:** 图片会根据尺寸转换为 token,并与文本 token 一起按输入 token 计费。 [了解更多](https://api-docs.deepseek.com/quick_start/pricing/)。 + +### 预估请求数 + +下表根据典型的 Go 使用模式提供了预估请求数: + +| Model | 每 5 小时请求数 | 每周请求数 | 每月请求数 | +| ----------------------------------------------------------- | ------------------------- | -------------------------- | --------------------------- | +| GLM-5.3-Flash | 6,320 | 15,790 | 31,580 | +| GLM-5.3 | 220 | 540 | 1,080 | +| GLM-5.2 | 880 | 2,150 | 4,300 | +| GLM-5.1 | 880 | 2,150 | 4,300 | +| Kimi K3 | 110 | 250 | 490 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | +| Kimi K2.6 | 1,150 | 2,880 | 5,750 | +| LongCat-2.0 | 11,400 | 28,600 | 57,200 | +| MiMo-V2.5 | 30,100 | 75,200 | 150,400 | +| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 3,200 | 8,000 | 16,000 | +| MiniMax M2.7 | 3,400 | 8,500 | 17,000 | +| Muse Spark 1.3 Contributor | 45,300 | 113,300 | 226,600 | +| Muse Spark 1.2 Contributor | 45,300 | 113,300 | 226,600 | +| Qwen3.8 Max | 160 | 400 | 810 | +| Qwen3.8 Flash | 5,400 | 13,500 | 27,000 | +| Qwen3.7 Max | 170 | 420 | 840 | +| Qwen3.7 Plus | 4,300 | 10,800 | 21,600 | +| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | +| DeepSeek V4.1 Flash
4x · 9 月 20 日结束 | ~~6,500~~
**26,000** | ~~16,250~~
**65,000** | ~~32,500~~
**130,000** | +| DeepSeek V4 Pro | 1,050 | 2,600 | 5,200 | +| DeepSeek V4 Flash | 13,000 | 32,500 | 65,000 | +| DeepSeek V4 Flash Vision Exp | 6,500 | 16,250 | 32,500 | +| Hy4 preview | 1,350 | 3,380 | 6,770 | +| Hy3 | 4,300 | 10,750 | 21,500 | +| Grok 4.6 | 169 | 423 | 845 | +| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 | + +预估值采用以下每次请求的 token 数量;实际使用情况会有所不同。 + +- Grok 4.6 — 每次请求 390 个输入 token,32,500 个缓存 token,120 个输出 token +- GLM-5.3-Flash — 每次请求 1,000 个输入 token,55,000 个缓存 token,200 个输出 token +- GLM-5.3/5.2/5.1 — 每次请求 700 个输入 token,52,000 个缓存 token,150 个输出 token +- GPT 5.6 Luna — 每次请求 1,000 个输入 token,50,000 个缓存 token,220 个输出 token +- Kimi K3 — 每次请求 1,050 个输入 token,76,500 个缓存 token,300 个输出 token +- Kimi K2.7/K2.6 — 每次请求 870 个输入 token,55,000 个缓存 token,200 个输出 token +- LongCat-2.0 — 每次请求 920 个输入 token,88,900 个缓存 token,200 个输出 token +- DeepSeek V4.1 Flash — 每次请求 410 个输入 token,71,300 个缓存 token,310 个输出 token +- DeepSeek V4 Pro — 每次请求 750 个输入 token,82,000 个缓存 token,290 个输出 token +- DeepSeek V4 Flash — 每次请求 410 个输入 token,71,300 个缓存 token,310 个输出 token +- DeepSeek V4 Flash Vision Exp — 每次请求 410 个输入 token,71,300 个缓存 token,310 个输出 token +- MiMo-V2.5 — 每次请求 830 个输入 token,71,500 个缓存 token,295 个输出 token +- MiMo-V2.5-Pro — 每次请求 790 个输入 token,86,000 个缓存 token,305 个输出 token +- MiniMax M3 — 每次请求 510 个输入 token,56,000 个缓存 token,190 个输出 token +- MiniMax M2.7 — 每次请求 300 个输入 token,55,000 个缓存 token,125 个输出 token +- Muse Spark 1.3 Contributor — 每次请求 620 个输入 token,71,400 个缓存 token,300 个输出 token +- Muse Spark 1.2 Contributor — 每次请求 620 个输入 token,71,400 个缓存 token,300 个输出 token +- Qwen3.8 Max — 每次请求 420 个输入 token,66,000 个缓存 token,200 个输出 token +- Qwen3.8 Flash — 每次请求 600 个输入 token,58,000 个缓存 token,200 个输出 token +- Qwen3.7 Max — 每次请求 420 个输入 token,66,000 个缓存 token,200 个输出 token +- Qwen3.7 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token +- Qwen3.6 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token +- Hy4 preview — 每次请求 830 个输入 token,71,500 个缓存 token,295 个输出 token +- Hy3 — 每次请求 830 个输入 token,71,500 个缓存 token,295 个输出 token + +你可以在 **控制台** 中跟踪你当前的使用情况。 + +:::tip +如果你达到了使用限制,你可以继续使用免费模型。 +::: + +使用限制可能会随着我们从早期使用和反馈中学习而发生变化。 + +--- + +### 超出限制的使用 + +如果你的 Zen 余额中还有积分,可以在控制台中启用 **使用余额(Use balance)** 选项。启用后,当你达到使用限制时,Go 会回退使用你的 Zen 余额,而不是拦截请求。 + +--- + +### 为什么某些模型的使用额度较低 + +使用 Go 时,你每月支付 $10,包含的每月使用额度因模型而异。 + +对于大多数模型,我们通过批量折扣和预留 GPU 容量来实现这一目标。然后,我们通过提高每月使用额度,将节省的成本回馈给你。 + +对于某些模型,我们还没有机会协商折扣或以更低的成本托管它们,这可能是因为模型较新,或者其公开价格已经是折扣价。 + +对于这些模型,你获得的使用额度仍会略高于直接向模型提供商付费;这就是它们包含的每月使用额度较低的原因。 + +--- + +## API 端点 + +你也可以通过以下 API 端点访问 Go 模型。 + +| 模型 | 模型 ID | 端点 | AI SDK 包 | +| ---------------------------- | ---------------------------- | ------------------------------------------------ | --------------------------- | +| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| GLM-5.3-Flash | glm-5.3-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.3 | glm-5.3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.2 | glm-5.2 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM-5.1 | glm-5.1 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| LongCat-2.0 | longcat-2.0 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4.1 Flash | deepseek-v4.1-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Vision Exp | deepseek-v4-flash-vision-exp | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Muse Spark 1.3 Contributor | muse-spark-1.3-contributor | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.2 Contributor | muse-spark-1.2-contributor | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` | +| Qwen3.8 Max | qwen3.8-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.8 Flash | qwen3.8-flash | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy4 preview | hy4-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | + +你的 OpenCode 配置中的 [模型 ID](/docs/config/#models) 使用 `opencode-go/` 格式。例如,对于 Kimi K3,你将在配置中使用 `opencode-go/kimi-k3`。 + +--- + +### 模型 + +你可以从以下地址获取可用模型及其元数据的完整列表: + +``` +https://opencode.ai/zen/go/v1/models +``` + +--- + +## 隐私保护 + +| 模型 | 模型训练 | 数据留存 | +| ---------------------------- | -------- | -------- | +| Grok 4.6 | 不使用 | 30 天 | +| GPT 5.6 Luna | 不使用 | 30 天 | +| GLM-5.3-Flash | 不使用 | 0 天 | +| GLM-5.3 | 不使用 | 0 天 | +| GLM-5.2 | 不使用 | 0 天 | +| GLM-5.1 | 不使用 | 0 天 | +| Kimi K3 | 不使用 | 0 天 | +| Kimi K2.7 Code | 不使用 | 0 天 | +| Kimi K2.6 | 不使用 | 0 天 | +| LongCat-2.0 | 不使用 | 0 天 | +| MiMo-V2.5-Pro | 不使用 | 0 天 | +| MiMo-V2.5 | 不使用 | 0 天 | +| Qwen3.8 Max | 不使用 | 0 天 | +| Qwen3.8 Flash | 不使用 | 0 天 | +| Qwen3.7 Max | 不使用 | 0 天 | +| Qwen3.7 Plus | 不使用 | 0 天 | +| Qwen3.6 Plus | 不使用 | 0 天 | +| MiniMax M3 | 不使用 | 0 天 | +| MiniMax M2.7 | 不使用 | 0 天 | +| Muse Spark 1.3 Contributor | 是 | 非 ZDR | +| Muse Spark 1.2 Contributor | 是 | 非 ZDR | +| DeepSeek V4.1 Flash | 不使用 | 0 天 | +| DeepSeek V4 Pro | 不使用 | 0 天 | +| DeepSeek V4 Flash | 不使用 | 0 天 | +| DeepSeek V4 Flash Vision Exp | 不使用 | 0 天 | +| Hy4 preview | 不使用 | 0 天 | +| Hy3 | 不使用 | 0 天 | + +- **Grok 4.6:** ZDR 会禁用依赖所存储数据的重要 API 功能,包括有状态的 Responses API、Files and Collections 和 Batch API。[了解更多](https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr)。 +- **GPT 5.6 Luna:** 所有 API 功能的使用都会生成滥用监控日志,并最多保留 30 天。[了解更多](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring)。 +- **Muse Spark 1.3 Contributor:** 以允许使用你的提示词和补全结果训练未来的 Meta 模型为交换,token 价格可获得大幅折扣。仅在 Meta 的[地理使用政策](https://ai.developer.meta.com/legal/geographic-use-policy)允许的地区提供。[了解更多](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier)。 +- **Muse Spark 1.2 Contributor:** 以允许使用你的提示词和补全结果训练未来的 Meta 模型为交换,token 价格可获得大幅折扣。仅在 Meta 的[地理使用政策](https://ai.developer.meta.com/legal/geographic-use-policy)允许的地区提供。[了解更多](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier)。 +- **DeepSeek:** ZDR 协议每月续签。当前协议有效期至 2026 年 9 月 30 日。 + +--- + +## 目标 + +我们创建 OpenCode Go 的目的是: + +1. 通过低成本订阅让更多人能够 **无门槛地** 使用 AI 编程。 +2. 为最佳开源编程模型提供 **可靠的** 访问。 +3. 精选经过 **测试和基准评估**,适合编程 Agent 使用的模型。 +4. **无锁定(no lock-in)**,允许你与 OpenCode 一起使用任何其他提供商。 diff --git a/packages/web/src/content/docs/zh-cn/ide.mdx b/packages/web/src/content/docs/zh-cn/ide.mdx new file mode 100644 index 0000000000000000000000000000000000000000..1a759a8e6a042540214d8dd2d2cc60d7d0cc6faf --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/ide.mdx @@ -0,0 +1,48 @@ +--- +title: IDE +description: 适用于 VS Code、Cursor 及其他 IDE 的 OpenCode 扩展 +--- + +OpenCode 可与 VS Code、Cursor 或任何支持终端的 IDE 集成。只需在终端中运行 `opencode` 即可开始使用。 + +--- + +## 用法 + +- **快速启动**:使用 `Cmd+Esc`(Mac)或 `Ctrl+Esc`(Windows/Linux)在分屏终端视图中打开 OpenCode,如果已有终端会话正在运行,则会自动聚焦到该会话。 +- **新建会话**:使用 `Cmd+Shift+Esc`(Mac)或 `Ctrl+Shift+Esc`(Windows/Linux)启动新的 OpenCode 终端会话,即使已有会话在运行也会新建。你也可以点击界面中的 OpenCode 按钮。 +- **上下文感知**:自动将当前选中内容或标签页共享给 OpenCode。 +- **文件引用快捷键**:使用 `Cmd+Option+K`(Mac)或 `Alt+Ctrl+K`(Linux/Windows)插入文件引用。例如 `@File#L37-42`。 + +--- + +## 安装 + +在 VS Code 及其常见分支(如 Cursor、Windsurf、VSCodium)上安装 OpenCode: + +1. 打开 VS Code +2. 打开集成终端 +3. 运行 `opencode`——扩展将自动安装 + +如果你希望在 TUI 中执行 `/editor` 或 `/export` 时使用自己的 IDE,需要设置 `export EDITOR="code --wait"`。[了解更多](/docs/tui/#editor-setup)。 + +--- + +### 手动安装 + +在扩展商店中搜索 **OpenCode**,然后点击 **Install**。 + +--- + +### 故障排除 + +如果扩展未能自动安装: + +- 确保你是在集成终端中运行的 `opencode`。 +- 确认你的 IDE 对应的 CLI 命令已安装: + - VS Code:`code` 命令 + - Cursor:`cursor` 命令 + - Windsurf:`windsurf` 命令 + - VSCodium:`codium` 命令 + - 如果未安装,请按 `Cmd+Shift+P`(Mac)或 `Ctrl+Shift+P`(Windows/Linux),搜索 "Shell Command: Install 'code' command in PATH"(或你的 IDE 对应的命令) +- 确保 VS Code 有权限安装扩展 diff --git a/packages/web/src/content/docs/zh-cn/index.mdx b/packages/web/src/content/docs/zh-cn/index.mdx new file mode 100644 index 0000000000000000000000000000000000000000..ed278e2397dc1d36650a2771083de48367111f80 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/index.mdx @@ -0,0 +1,343 @@ +--- +title: 简介 +description: 开始使用 OpenCode。 +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" +import config from "../../../../config.mjs" +export const console = config.console + +[**OpenCode**](/) 是一个开源的 AI 编码代理。它提供终端界面、桌面应用和 IDE 扩展等多种使用方式。 + +![使用 opencode 主题的 OpenCode TUI](../../../assets/lander/screenshot.png) + +让我们开始吧。 + +--- + +#### 前提条件 + +要在终端中使用 OpenCode,你需要: + +1. 一款现代终端模拟器,例如: + - [WezTerm](https://wezterm.org),跨平台 + - [Alacritty](https://alacritty.org),跨平台 + - [Ghostty](https://ghostty.org),Linux 和 macOS + - [Kitty](https://sw.kovidgoyal.net/kitty/),Linux 和 macOS + +2. 你想使用的 LLM 提供商的 API 密钥。 + +--- + +## 安装 + +安装 OpenCode 最简单的方法是通过安装脚本。 + +```bash +curl -fsSL https://opencode.ai/install | bash +``` + +你也可以使用以下方式安装: + +- **使用 Node.js** + + + + + ```bash + npm install -g opencode-ai + ``` + + + + + ```bash + bun install -g opencode-ai + ``` + + + + + ```bash + pnpm install -g opencode-ai + ``` + + + + + ```bash + yarn global add opencode-ai + ``` + + + + + +- **在 macOS 和 Linux 上使用 Homebrew** + + ```bash + brew install anomalyco/tap/opencode + ``` + + > 我们推荐使用 OpenCode tap 以获取最新版本。官方的 `brew install opencode` formula 由 Homebrew 团队维护,更新频率较低。 + +- **在 Arch Linux 上安装** + + ```bash + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) + ``` + +#### Windows + +:::tip[推荐:使用 WSL] +为了在 Windows 上获得最佳体验,我们推荐使用 [Windows Subsystem for Linux (WSL)](/docs/windows-wsl)。它提供更好的性能,并完全兼容 OpenCode 的所有功能。 +::: + +- **使用 Chocolatey** + + ```bash + choco install opencode + ``` + +- **使用 Scoop** + + ```bash + scoop install opencode + ``` + +- **使用 NPM** + + ```bash + npm install -g opencode-ai + ``` + +- **使用 Mise** + + ```bash + mise use -g github:anomalyco/opencode + ``` + +- **使用 Docker** + + ```bash + docker run -it --rm ghcr.io/anomalyco/opencode + ``` + +在 Windows 上通过 Bun 安装 OpenCode 的支持目前正在开发中。 + +你也可以从 [Releases](https://github.com/anomalyco/opencode/releases) 页面直接下载二进制文件。 + +--- + +## 配置 + +通过 OpenCode,你可以配置 API 密钥来使用任意 LLM 提供商。 + +如果你刚开始接触 LLM 提供商,我们推荐使用 [OpenCode Zen](/docs/zen)。这是一组经过 OpenCode 团队测试和验证的精选模型。 + +1. 在 TUI 中运行 `/connect` 命令,选择 opencode,然后前往 [opencode.ai/auth](https://opencode.ai/auth)。 + + ```txt + /connect + ``` + +2. 登录并添加账单信息,然后复制你的 API 密钥。 + +3. 粘贴你的 API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +你也可以选择其他提供商。[了解更多](/docs/providers#directory)。 + +--- + +## 初始化 + +配置好提供商后,导航到你想要处理的项目目录。 + +```bash +cd /path/to/project +``` + +然后运行 OpenCode。 + +```bash +opencode +``` + +接下来,运行以下命令为项目初始化 OpenCode。 + +```bash frame="none" +/init +``` + +OpenCode 会分析你的项目并在项目根目录创建一个 `AGENTS.md` 文件。 + +:::tip +你应该将项目的 `AGENTS.md` 文件提交到 Git。 +::: + +这有助于 OpenCode 理解项目结构和编码规范。 + +--- + +## 使用 + +现在你已经准备好使用 OpenCode 来处理项目了,尽管提问吧! + +如果你是第一次使用 AI 编码代理,以下示例可能会对你有所帮助。 + +--- + +### 提问 + +你可以让 OpenCode 为你讲解代码库。 + +:::tip +使用 `@` 键可以模糊搜索项目中的文件。 +::: + +```txt frame="none" "@packages/functions/src/api/index.ts" +How is authentication handled in @packages/functions/src/api/index.ts +``` + +当你遇到不熟悉的代码时,这个功能非常有用。 + +--- + +### 添加功能 + +你可以让 OpenCode 为项目添加新功能。不过我们建议先让它制定一个计划。 + +1. **制定计划** + + OpenCode 有一个*计划模式*,该模式下它不会进行任何修改,而是建议*如何*实现该功能。 + + 使用 **Tab** 键切换到计划模式。你会在右下角看到模式指示器。 + + ```bash frame="none" title="Switch to Plan mode" + + ``` + + 接下来描述你希望它做什么。 + + ```txt frame="none" + When a user deletes a note, we'd like to flag it as deleted in the database. + Then create a screen that shows all the recently deleted notes. + From this screen, the user can undelete a note or permanently delete it. + ``` + + 你需要提供足够的细节,让 OpenCode 理解你的需求。可以把它当作团队中的一名初级开发者来沟通。 + + :::tip + 为 OpenCode 提供充足的上下文和示例,帮助它理解你的需求。 + ::: + +2. **迭代计划** + + 当它给出计划后,你可以提供反馈或补充更多细节。 + + ```txt frame="none" + We'd like to design this new screen using a design I've used before. + [Image #1] Take a look at this image and use it as a reference. + ``` + + :::tip + 将图片拖放到终端中即可将其添加到提示词中。 + ::: + + OpenCode 可以扫描你提供的图片并将其添加到提示词中。只需将图片拖放到终端窗口即可。 + +3. **构建功能** + + 当你对计划满意后,再次按 **Tab** 键切换回*构建模式*。 + + ```bash frame="none" + + ``` + + 然后让它开始实施。 + + ```bash frame="none" + Sounds good! Go ahead and make the changes. + ``` + +--- + +### 直接修改 + +对于比较简单的修改,你可以直接让 OpenCode 实施,无需先审查计划。 + +```txt frame="none" "@packages/functions/src/settings.ts" "@packages/functions/src/notes.ts" +We need to add authentication to the /settings route. Take a look at how this is +handled in the /notes route in @packages/functions/src/notes.ts and implement +the same logic in @packages/functions/src/settings.ts +``` + +请确保提供足够的细节,以便 OpenCode 做出正确的修改。 + +--- + +### 撤销修改 + +假设你让 OpenCode 做了一些修改。 + +```txt frame="none" "@packages/functions/src/api/index.ts" +Can you refactor the function in @packages/functions/src/api/index.ts? +``` + +但你发现结果不是你想要的。你**可以使用** `/undo` 命令来撤销修改。 + +```bash frame="none" +/undo +``` + +OpenCode 会还原所做的修改,并重新显示你之前的消息。 + +```txt frame="none" "@packages/functions/src/api/index.ts" +Can you refactor the function in @packages/functions/src/api/index.ts? +``` + +你可以调整提示词,让 OpenCode 重新尝试。 + +:::tip +你可以多次运行 `/undo` 来撤销多次修改。 +::: + +你也**可以使用** `/redo` 命令来重做修改。 + +```bash frame="none" +/redo +``` + +--- + +## 分享 + +你与 OpenCode 的对话可以[与团队分享](/docs/share)。 + +```bash frame="none" +/share +``` + +这会生成当前对话的链接并复制到剪贴板。 + +:::note +对话默认不会被分享。 +::: + +这是一个与 OpenCode 的[示例对话](https://opencode.ai/s/4XP1fce5)。 + +--- + +## 个性化 + +以上就是全部内容!你现在已经是 OpenCode 的使用高手了。 + +要让它更符合你的习惯,我们推荐[选择一个主题](/docs/themes)、[自定义快捷键](/docs/keybinds)、[配置代码格式化工具](/docs/formatters)、[创建自定义命令](/docs/commands),或者探索 [OpenCode 配置](/docs/config)。 diff --git a/packages/web/src/content/docs/zh-cn/keybinds.mdx b/packages/web/src/content/docs/zh-cn/keybinds.mdx new file mode 100644 index 0000000000000000000000000000000000000000..0989a30f570bc70f0804c58fbd7fb71f2d9d40ee --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/keybinds.mdx @@ -0,0 +1,194 @@ +--- +title: 快捷键 +description: 自定义您的快捷键。 +--- + +OpenCode 提供了一系列快捷键,您可以通过 `tui.json` 进行自定义。 + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keybinds": { + "leader": "ctrl+x", + "app_exit": "ctrl+c,ctrl+d,q", + "editor_open": "e", + "theme_list": "t", + "sidebar_toggle": "b", + "scrollbar_toggle": "none", + "username_toggle": "none", + "status_view": "s", + "tool_details": "none", + "session_export": "x", + "session_new": "n", + "session_list": "l", + "session_timeline": "g", + "session_fork": "none", + "session_rename": "none", + "session_share": "none", + "session_unshare": "none", + "session_interrupt": "escape", + "session_compact": "c", + "session_child_first": "down", + "session_child_cycle": "right", + "session_child_cycle_reverse": "left", + "session_parent": "up", + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", + "messages_half_page_up": "ctrl+alt+u", + "messages_half_page_down": "ctrl+alt+d", + "messages_first": "ctrl+g,home", + "messages_last": "ctrl+alt+g,end", + "messages_next": "none", + "messages_previous": "none", + "messages_copy": "y", + "messages_undo": "u", + "messages_redo": "r", + "messages_last_user": "none", + "messages_toggle_conceal": "h", + "model_list": "m", + "model_cycle_recent": "f2", + "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", + "variant_cycle": "ctrl+t", + "variant_list": "none", + "command_list": "ctrl+p", + "agent_list": "a", + "agent_cycle": "tab", + "agent_cycle_reverse": "shift+tab", + "input_clear": "ctrl+c", + "input_paste": "ctrl+v", + "input_submit": "return", + "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input_move_left": "left,ctrl+b", + "input_move_right": "right,ctrl+f", + "input_move_up": "up", + "input_move_down": "down", + "input_select_left": "shift+left", + "input_select_right": "shift+right", + "input_select_up": "shift+up", + "input_select_down": "shift+down", + "input_line_home": "ctrl+a", + "input_line_end": "ctrl+e", + "input_select_line_home": "ctrl+shift+a", + "input_select_line_end": "ctrl+shift+e", + "input_visual_line_home": "alt+a", + "input_visual_line_end": "alt+e", + "input_select_visual_line_home": "alt+shift+a", + "input_select_visual_line_end": "alt+shift+e", + "input_buffer_home": "home", + "input_buffer_end": "end", + "input_select_buffer_home": "shift+home", + "input_select_buffer_end": "shift+end", + "input_delete_line": "ctrl+shift+d", + "input_delete_to_line_end": "ctrl+k", + "input_delete_to_line_start": "ctrl+u", + "input_backspace": "backspace,shift+backspace", + "input_delete": "ctrl+d,delete,shift+delete", + "input_undo": "ctrl+-,super+z", + "input_redo": "ctrl+.,super+shift+z", + "input_word_forward": "alt+f,alt+right,ctrl+right", + "input_word_backward": "alt+b,alt+left,ctrl+left", + "input_select_word_forward": "alt+shift+f,alt+shift+right", + "input_select_word_backward": "alt+shift+b,alt+shift+left", + "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", + "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", + "history_previous": "up", + "history_next": "down", + "terminal_suspend": "ctrl+z", + "terminal_title_toggle": "none", + "tips_toggle": "h", + "display_thinking": "none" + } +} +``` + +--- + +## 前导键 + +OpenCode 的大多数快捷键使用 `leader`(前导键)。这可以避免与终端中的其他快捷键冲突。 + +默认情况下,`ctrl+x` 是前导键,大多数操作需要您先按下前导键,然后再按对应的快捷键。例如,要新建一个会话,请先按 `ctrl+x`,然后按 `n`。 + +您不一定需要使用前导键来设置快捷键,但我们建议您这样做。 + +--- + +## 禁用快捷键 + +您可以通过将键值添加到 `tui.json` 并设置为 "none" 来禁用某个快捷键。 + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keybinds": { + "session_compact": "none" + } +} +``` + +--- + +## 桌面版提示词输入快捷键 + +OpenCode 桌面应用的提示词输入框支持常见的 Readline/Emacs 风格文本编辑快捷键。这些快捷键为内置功能,目前无法通过 `opencode.json` 进行配置。 + +| 快捷键 | 操作 | +| -------- | --------------------------------- | +| `ctrl+a` | 移动到当前行的开头 | +| `ctrl+e` | 移动到当前行的末尾 | +| `ctrl+b` | 光标向后移动一个字符 | +| `ctrl+f` | 光标向前移动一个字符 | +| `alt+b` | 光标向后移动一个单词 | +| `alt+f` | 光标向前移动一个单词 | +| `ctrl+d` | 删除光标所在位置的字符 | +| `ctrl+k` | 删除从光标到行尾的内容 | +| `ctrl+u` | 删除从光标到行首的内容 | +| `ctrl+w` | 删除前一个单词 | +| `alt+d` | 删除后一个单词 | +| `ctrl+t` | 交换光标前后的字符 | +| `ctrl+g` | 取消弹出窗口 / 中止正在运行的响应 | + +--- + +## Shift+Enter + +某些终端默认不会发送带修饰键的 Enter 键。您可能需要配置终端将 `Shift+Enter` 作为转义序列发送。 + +### Windows Terminal + +打开您的 `settings.json` 文件,路径为: + +``` +%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json +``` + +将以下内容添加到根级 `actions` 数组中: + +```json +"actions": [ + { + "command": { + "action": "sendInput", + "input": "\u001b[13;2u" + }, + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +将以下内容添加到根级 `keybindings` 数组中: + +```json +"keybindings": [ + { + "keys": "shift+enter", + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +保存文件并重启 Windows Terminal,或打开一个新标签页。 diff --git a/packages/web/src/content/docs/zh-cn/lsp.mdx b/packages/web/src/content/docs/zh-cn/lsp.mdx new file mode 100644 index 0000000000000000000000000000000000000000..07466c384ec24c5c471ea84360a53bb7f2e67402 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/lsp.mdx @@ -0,0 +1,208 @@ +--- +title: LSP 服务器 +description: OpenCode 与你的 LSP 服务器集成。 +--- + +OpenCode 可以与语言服务器协议(LSP)服务器集成,将诊断信息作为 agent 的反馈。 + +--- + +## 内置支持 + +OpenCode 内置了多种适用于主流语言的 LSP 服务器: + +| LSP 服务器 | 扩展名 | 要求 | +| ------------------ | ------------------------------------------------------------------- | ----------------------------------------------------- | +| astro | .astro | 为 Astro 项目自动安装 | +| bash | .sh, .bash, .zsh, .ksh | 自动安装 bash-language-server | +| clangd | .c, .cpp, .cc, .cxx, .c++, .h, .hpp, .hh, .hxx, .h++ | 为 C/C++ 项目自动安装 | +| csharp | .cs | 需要已安装 `.NET SDK` | +| clojure-lsp | .clj, .cljs, .cljc, .edn | 需要 `clojure-lsp` 命令可用 | +| dart | .dart | 需要 `dart` 命令可用 | +| deno | .ts, .tsx, .js, .jsx, .mjs | 需要 `deno` 命令可用(自动检测 deno.json/deno.jsonc) | +| elixir-ls | .ex, .exs | 需要 `elixir` 命令可用 | +| eslint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue | 项目中需要 `eslint` 依赖 | +| fsharp | .fs, .fsi, .fsx, .fsscript | 需要已安装 `.NET SDK` | +| gleam | .gleam | 需要 `gleam` 命令可用 | +| gopls | .go | 需要 `go` 命令可用 | +| hls | .hs, .lhs | 需要 `haskell-language-server-wrapper` 命令可用 | +| jdtls | .java | 需要已安装 `Java SDK (version 21+)` | +| julials | .jl | 需要安装 `julia` and `LanguageServer.jl` | +| kotlin-ls | .kt, .kts | 为 Kotlin 项目自动安装 | +| lua-ls | .lua | 为 Lua 项目自动安装 | +| nixd | .nix | 需要 `nixd` 命令可用 | +| ocaml-lsp | .ml, .mli | 需要 `ocamllsp` 命令可用 | +| oxlint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue, .astro, .svelte | 项目中需要 `oxlint` 依赖 | +| php intelephense | .php | 为 PHP 项目自动安装 | +| prisma | .prisma | 需要 `prisma` 命令可用 | +| pyright | .py, .pyi | 需要已安装 `pyright` 依赖 | +| ruby-lsp (rubocop) | .rb, .rake, .gemspec, .ru | 需要 `ruby` 和 `gem` 命令可用 | +| rust | .rs | 需要 `rust-analyzer` 命令可用 | +| sourcekit-lsp | .swift, .objc, .objcpp | 需要已安装 `swift`(macOS 上为 `xcode`) | +| svelte | .svelte | 为 Svelte 项目自动安装 | +| terraform | .tf, .tfvars | 从 GitHub releases 自动安装 | +| tinymist | .typ, .typc | 从 GitHub releases 自动安装 | +| typescript | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts | 项目中需要 `typescript` 依赖 | +| vue | .vue | 为 Vue 项目自动安装 | +| yaml-ls | .yaml, .yml | 自动安装 Red Hat yaml-language-server | +| zls | .zig, .zon | 需要 `zig` 命令可用 | + +LSP 默认关闭。启用后,当检测到上述文件扩展名且满足相应要求时,服务器会启动。 + +:::note +你可以将 `OPENCODE_DISABLE_LSP_DOWNLOAD` 环境变量设置为 `true` 来禁用 LSP 服务器的自动下载。 +::: + +--- + +## 工作原理 + +启用 LSP 且 opencode 打开文件时,它会: + +1. 将文件扩展名与所有已启用的 LSP 服务器进行匹配。 +2. 如果对应的 LSP 服务器尚未运行,则自动启动它。 + +--- + +## 最佳实践 + +LSP 可以通过语言服务器诊断帮助 agent 发现并修复问题。这对某些项目很有用,但并不总是带来净收益。 + +语言服务器可能与项目不同步、占用较多内存、随版本或项目表现不同,并拖慢 agent 工作流。在许多项目中,更好的做法是让 agent 直接运行 lint、typecheck 或其他诊断类 CLI 工具,这样错误会进入 agent 循环,同时避免这些权衡。将这些命令记录在 `AGENTS.md` 或 skills 等指令文件中,让 agent 知道该运行什么。当你的项目能从额外的语言服务器反馈中受益时再启用 LSP。 + +--- + +## 配置 + +你可以通过 opencode 配置文件中的 `lsp` 部分来启用并自定义 LSP 服务器。 + +要启用所有内置 LSP 服务器,请将 `lsp` 设置为 `true`。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +使用对象可以在保持内置服务器启用的同时配置覆盖项或自定义服务器。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": {} +} +``` + +每个 LSP 服务器支持以下配置项: + +| 属性 | 类型 | 描述 | +| ---------------- | -------- | --------------------------------- | +| `disabled` | boolean | 设置为 `true` 可禁用该 LSP 服务器 | +| `command` | string[] | 启动 LSP 服务器的命令 | +| `extensions` | string[] | 该 LSP 服务器需要处理的文件扩展名 | +| `env` | object | 启动服务器时设置的环境变量 | +| `initialization` | object | 发送给 LSP 服务器的初始化选项 | + +下面来看一些示例。 + +--- + +### 环境变量 + +使用 `env` 属性在启动 LSP 服务器时设置环境变量: + +```json title="opencode.json" {5-7} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "rust": { + "env": { + "RUST_LOG": "debug" + } + } + } +} +``` + +--- + +### 初始化选项 + +使用 `initialization` 属性向 LSP 服务器传递初始化选项。这些是在 LSP `initialize` 请求期间发送的服务器特定设置: + +```json title="opencode.json" {5-9} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "initialization": { + "preferences": { + "importModuleSpecifierPreference": "relative" + } + } + } + } +} +``` + +:::note +初始化选项因 LSP 服务器而异。请查阅你所使用的 LSP 服务器的文档以了解可用选项。 +::: + +--- + +### 禁用 LSP 服务器 + +如果省略 `lsp`,所有 LSP 服务器都会被禁用。如果另一个配置启用了 LSP,可将 `lsp` 设置为 `false` 来禁用所有 LSP 服务器: + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": false +} +``` + +要禁用**特定的** LSP 服务器,将 `disabled` 设置为 `true`: + +```json title="opencode.json" {5} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "disabled": true + } + } +} +``` + +--- + +### 自定义 LSP 服务器 + +你可以通过指定命令和文件扩展名来添加自定义 LSP 服务器: + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "custom-lsp": { + "command": ["custom-lsp-server", "--stdio"], + "extensions": [".custom"] + } + } +} +``` + +--- + +## 补充信息 + +### PHP Intelephense + +PHP Intelephense 通过许可证密钥提供高级功能。你可以将许可证密钥单独放在以下路径的文本文件中: + +- macOS/Linux:`$HOME/intelephense/license.txt` +- Windows:`%USERPROFILE%/intelephense/license.txt` + +该文件应仅包含许可证密钥,不要添加其他任何内容。 diff --git a/packages/web/src/content/docs/zh-cn/mcp-servers.mdx b/packages/web/src/content/docs/zh-cn/mcp-servers.mdx new file mode 100644 index 0000000000000000000000000000000000000000..cac8778d485bf446d0476c550c615e84992949fd --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/mcp-servers.mdx @@ -0,0 +1,511 @@ +--- +title: MCP 服务器 +description: 添加本地和远程 MCP 工具。 +--- + +你可以通过 _Model Context Protocol_(MCP)为 OpenCode 添加外部工具。OpenCode 同时支持本地和远程服务器。 + +添加后,MCP 工具会自动与内置工具一起提供给 LLM 使用。 + +--- + +#### 注意事项 + +使用 MCP 服务器时,它会占用上下文空间。如果你启用了大量工具,上下文消耗会迅速增加。因此,我们建议谨慎选择要使用的 MCP 服务器。 + +:::tip +MCP 服务器会占用你的上下文空间,所以请谨慎选择启用哪些服务器。 +::: + +某些 MCP 服务器(例如 GitHub MCP 服务器)往往会消耗大量 Token,很容易超出上下文限制。 + +--- + +## 启用 + +你可以在 [OpenCode 配置](https://opencode.ai/docs/config/)的 `mcp` 字段下定义 MCP 服务器。为每个 MCP 指定一个唯一的名称,在提示词中可以通过该名称来引用对应的 MCP。 + +```jsonc title="opencode.jsonc" {6} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "name-of-mcp-server": { + // ... + "enabled": true, + }, + "name-of-other-mcp-server": { + // ... + }, + }, +} +``` + +你也可以将 `enabled` 设置为 `false` 来禁用某个服务器。当你想临时禁用某个服务器而不将其从配置中移除时,这个选项非常有用。 + +--- + +### 覆盖远程默认值 + +组织可以通过其 `.well-known/opencode` 端点提供默认的 MCP 服务器。这些服务器可能默认处于禁用状态,允许用户按需启用。 + +要启用组织远程配置中的某个服务器,请在本地配置中添加该服务器并设置 `enabled: true`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } + } +} +``` + +本地配置值会覆盖远程默认值。详情请参阅[配置优先级](/docs/config#precedence-order)。 + +--- + +## 本地 + +通过在 MCP 对象中将 `type` 设置为 `"local"` 来添加本地 MCP 服务器。 + +```jsonc title="opencode.jsonc" {15} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-local-mcp-server": { + "type": "local", + // Or ["bun", "x", "my-mcp-command"] + "command": ["npx", "-y", "my-mcp-command"], + "enabled": true, + "environment": { + "MY_ENV_VAR": "my_env_var_value", + }, + }, + }, +} +``` + +`command` 用于指定本地 MCP 服务器的启动命令。你还可以传入一组环境变量。 + +例如,以下是添加测试用的 [`@modelcontextprotocol/server-everything`](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) MCP 服务器的方法。 + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "mcp_everything": { + "type": "local", + "command": ["npx", "-y", "@modelcontextprotocol/server-everything"], + }, + }, +} +``` + +要使用它,可以在提示词中添加 `use the mcp_everything tool`。 + +```txt "mcp_everything" +use the mcp_everything tool to add the number 3 and 4 +``` + +--- + +#### 选项 + +以下是配置本地 MCP 服务器的所有选项。 + +| 选项 | 类型 | 必填 | 描述 | +| ------------- | ------ | ---- | ----------------------------------------------------------------- | +| `type` | 字符串 | 是 | MCP 服务器连接类型,必须为 `"local"`。 | +| `command` | 数组 | 是 | 运行 MCP 服务器的命令及参数。 | +| `environment` | 对象 | | 运行服务器时设置的环境变量。 | +| `enabled` | 布尔值 | | 启动时启用或禁用该 MCP 服务器。 | +| `timeout` | 数字 | | 从 MCP 服务器获取工具的超时时间(毫秒)。默认为 5000(即 5 秒)。 | + +--- + +## 远程 + +通过将 `type` 设置为 `"remote"` 来添加远程 MCP 服务器。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-remote-mcp": { + "type": "remote", + "url": "https://my-mcp-server.com", + "enabled": true, + "headers": { + "Authorization": "Bearer MY_API_KEY" + } + } + } +} +``` + +`url` 是远程 MCP 服务器的地址,通过 `headers` 选项可以传入一组请求头。 + +--- + +#### 选项 + +| 选项 | 类型 | 必填 | 描述 | +| --------- | ------ | ---- | ----------------------------------------------------------------- | +| `type` | 字符串 | 是 | MCP 服务器连接类型,必须为 `"remote"`。 | +| `url` | 字符串 | 是 | 远程 MCP 服务器的 URL。 | +| `enabled` | 布尔值 | | 启动时启用或禁用该 MCP 服务器。 | +| `headers` | 对象 | | 随请求发送的请求头。 | +| `oauth` | 对象 | | OAuth 身份验证配置。详见下方 [OAuth](#oauth) 部分。 | +| `timeout` | 数字 | | 从 MCP 服务器获取工具的超时时间(毫秒)。默认为 5000(即 5 秒)。 | + +--- + +## OAuth + +OpenCode 会自动处理远程 MCP 服务器的 OAuth 身份验证。当服务器需要身份验证时,OpenCode 将: + +1. 检测 401 响应并启动 OAuth 流程 +2. 在服务器支持的情况下使用**动态客户端注册(RFC 7591)** +3. 安全地存储 Token 以供后续请求使用 + +--- + +### 自动认证 + +对于大多数支持 OAuth 的 MCP 服务器,无需特殊配置。只需配置远程服务器即可: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp" + } + } +} +``` + +如果服务器需要身份验证,OpenCode 会在你首次使用时提示你进行认证。你也可以使用 `opencode mcp auth ` [手动触发认证流程](#authenticating)。 + +--- + +### 预注册 + +如果你已经从 MCP 服务器提供商处获得了客户端凭据,可以直接配置: + +```json title="opencode.json" {7-11} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "{env:MY_MCP_CLIENT_ID}", + "clientSecret": "{env:MY_MCP_CLIENT_SECRET}", + "scope": "tools:read tools:execute" + } + } + } +} +``` + +--- + +### 身份验证 + +你可以手动触发身份验证或管理凭据。 + +对特定 MCP 服务器进行身份验证: + +```bash +opencode mcp auth my-oauth-server +``` + +列出所有 MCP 服务器及其认证状态: + +```bash +opencode mcp list +``` + +删除已存储的凭据: + +```bash +opencode mcp logout my-oauth-server +``` + +`mcp auth` 命令会打开浏览器进行授权。授权完成后,OpenCode 会将 Token 安全地存储在 `~/.local/share/opencode/mcp-auth.json` 中。 + +--- + +#### 禁用 OAuth + +如果你想为某个服务器禁用自动 OAuth(例如,该服务器使用 API 密钥而非 OAuth),可以将 `oauth` 设置为 `false`: + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-api-key-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": false, + "headers": { + "Authorization": "Bearer {env:MY_API_KEY}" + } + } + } +} +``` + +--- + +#### OAuth 选项 + +| 选项 | 类型 | 描述 | +| -------------- | --------------- | ------------------------------------------------------ | +| `oauth` | 对象 \| `false` | OAuth 配置对象,或设为 `false` 以禁用 OAuth 自动检测。 | +| `clientId` | 字符串 | OAuth 客户端 ID。如果未提供,将尝试动态客户端注册。 | +| `clientSecret` | 字符串 | OAuth 客户端密钥(如果授权服务器要求提供)。 | +| `scope` | 字符串 | 授权时请求的 OAuth 作用域。 | + +#### 调试 + +如果远程 MCP 服务器身份验证失败,你可以通过以下方式诊断问题: + +```bash +# 查看所有支持 OAuth 的服务器的认证状态 +opencode mcp auth list + +# 调试特定服务器的连接和 OAuth 流程 +opencode mcp debug my-oauth-server +``` + +`mcp debug` 命令会显示当前认证状态、测试 HTTP 连接,并尝试执行 OAuth 发现流程。 + +--- + +## 管理 + +你的 MCP 在 OpenCode 中作为工具使用,与内置工具并列。因此,你可以像管理其他工具一样,通过 OpenCode 配置来管理它们。 + +--- + +### 全局 + +你可以全局启用或禁用 MCP 工具。 + +```json title="opencode.json" {14} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-foo"] + }, + "my-mcp-bar": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-bar"] + } + }, + "tools": { + "my-mcp-foo": false + } +} +``` + +也可以使用 glob 模式来禁用所有匹配的 MCP。 + +```json title="opencode.json" {14} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-foo"] + }, + "my-mcp-bar": { + "type": "local", + "command": ["bun", "x", "my-mcp-command-bar"] + } + }, + "tools": { + "my-mcp*": false + } +} +``` + +这里使用 glob 模式 `my-mcp*` 来禁用所有 MCP。 + +--- + +### 按代理配置 + +如果你有大量 MCP 服务器,可以选择全局禁用它们,然后仅在特定代理中启用。具体做法: + +1. 全局禁用该工具。 +2. 在[代理配置](/docs/agents#tools)中,将 MCP 服务器作为工具启用。 + +```json title="opencode.json" {11, 14-18} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp": { + "type": "local", + "command": ["bun", "x", "my-mcp-command"], + "enabled": true + } + }, + "tools": { + "my-mcp*": false + }, + "agent": { + "my-agent": { + "tools": { + "my-mcp*": true + } + } + } +} +``` + +--- + +#### Glob 模式 + +glob 模式使用简单的正则通配符规则: + +- `*` 匹配零个或多个任意字符(例如,`"my-mcp*"` 匹配 `my-mcp_search`、`my-mcp_list` 等) +- `?` 匹配恰好一个字符 +- 其他字符按字面值匹配 + +:::note +MCP 服务器工具在注册时以服务器名称作为前缀,因此要禁用某个服务器的所有工具,只需使用: + +``` +"mymcpservername_*": false +``` + +::: + +--- + +## 示例 + +以下是一些常见 MCP 服务器的配置示例。如果你想记录其他服务器的用法,欢迎提交 PR。 + +--- + +### Sentry + +添加 [Sentry MCP 服务器](https://mcp.sentry.dev) 以与你的 Sentry 项目和问题进行交互。 + +```json title="opencode.json" {4-8} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "sentry": { + "type": "remote", + "url": "https://mcp.sentry.dev/mcp", + "oauth": {} + } + } +} +``` + +添加配置后,使用 Sentry 进行身份验证: + +```bash +opencode mcp auth sentry +``` + +这会打开浏览器窗口完成 OAuth 流程,将 OpenCode 连接到你的 Sentry 账户。 + +认证完成后,你可以在提示词中使用 Sentry 工具来查询问题、项目和错误数据。 + +```txt "use sentry" +Show me the latest unresolved issues in my project. use sentry +``` + +--- + +### Context7 + +添加 [Context7 MCP 服务器](https://github.com/upstash/context7) 以搜索文档。 + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp" + } + } +} +``` + +如果你注册了免费账户,可以使用 API 密钥来获得更高的速率限制。 + +```json title="opencode.json" {7-9} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + } + } + } +} +``` + +这里假设你已经设置了 `CONTEXT7_API_KEY` 环境变量。 + +在提示词中添加 `use context7` 即可使用 Context7 MCP 服务器。 + +```txt "use context7" +Configure a Cloudflare Worker script to cache JSON API responses for five minutes. use context7 +``` + +你也可以在 [AGENTS.md](/docs/rules/) 中添加类似的规则。 + +```md title="AGENTS.md" +When you need to search docs, use `context7` tools. +``` + +--- + +### Grep by Vercel + +添加 [Grep by Vercel](https://grep.app) MCP 服务器以搜索 GitHub 上的代码片段。 + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "gh_grep": { + "type": "remote", + "url": "https://mcp.grep.app" + } + } +} +``` + +由于我们将 MCP 服务器命名为 `gh_grep`,你可以在提示词中添加 `use the gh_grep tool` 来让代理使用它。 + +```txt "use the gh_grep tool" +What's the right way to set a custom domain in an SST Astro component? use the gh_grep tool +``` + +你也可以在 [AGENTS.md](/docs/rules/) 中添加类似的规则。 + +```md title="AGENTS.md" +If you are unsure how to do something, use `gh_grep` to search code examples from GitHub. +``` diff --git a/packages/web/src/content/docs/zh-cn/models.mdx b/packages/web/src/content/docs/zh-cn/models.mdx new file mode 100644 index 0000000000000000000000000000000000000000..5399a59abd623c734bb2fbb9cb800abbca368ad2 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/models.mdx @@ -0,0 +1,222 @@ +--- +title: 模型 +description: 配置 LLM 提供商和模型。 +--- + +OpenCode 使用 [AI SDK](https://ai-sdk.dev/) 和 [Models.dev](https://models.dev) 支持 **75+ LLM 提供商**,并支持运行本地模型。 + +--- + +## 提供商 + +大多数热门提供商已默认预加载。如果你通过 `/connect` 命令添加了提供商的凭据,它们将在你启动 OpenCode 时自动可用。 + +了解更多关于[提供商](/docs/providers)的信息。 + +--- + +## 选择模型 + +配置好提供商后,你可以通过输入以下命令来选择想要使用的模型: + +```bash frame="none" +/models +``` + +--- + +## 推荐模型 + +市面上有非常多的模型,每周都有新模型发布。 + +:::tip +建议使用我们推荐的模型。 +::: + +然而,真正擅长代码生成和工具调用的模型只有少数几个。 + +以下是与 OpenCode 配合良好的几个模型,排名不分先后(此列表并非详尽无遗,也不一定是最新的): + +- GPT 5.2 +- GPT 5.1 Codex +- Claude Opus 4.5 +- Claude Sonnet 4.5 +- Minimax M2.1 +- Gemini 3 Pro + +--- + +## 设置默认模型 + +要将某个模型设为默认模型,可以在 OpenCode 配置中设置 `model` 字段。 + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "model": "lmstudio/google/gemma-3n-e4b" +} +``` + +这里完整的 ID 格式为 `provider_id/model_id`。例如,如果你使用 [OpenCode Zen](/docs/zen),则 GPT 5.1 Codex 对应的值为 `opencode/gpt-5.1-codex`。 + +如果你配置了[自定义提供商](/docs/providers#custom),`provider_id` 是配置中 `provider` 部分的键名,`model_id` 是 `provider.models` 中的键名。 + +--- + +## 配置模型 + +你可以通过配置文件全局配置模型的选项。 + +```jsonc title="opencode.jsonc" {7-12,19-24} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openai": { + "models": { + "gpt-5": { + "options": { + "reasoningEffort": "high", + "textVerbosity": "low", + "reasoningSummary": "auto", + "include": ["reasoning.encrypted_content"], + }, + }, + }, + }, + "anthropic": { + "models": { + "claude-sonnet-4-5-20250929": { + "options": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000, + }, + }, + }, + }, + }, + }, +} +``` + +这里我们为两个内置模型配置了全局设置:通过 `openai` 提供商访问的 `gpt-5`,以及通过 `anthropic` 提供商访问的 `claude-sonnet-4-20250514`。 +内置的提供商和模型名称可以在 [Models.dev](https://models.dev) 上查阅。 + +你还可以为使用中的任何代理配置这些选项。代理配置会覆盖此处的全局选项。[了解更多](/docs/agents/#additional)。 + +你也可以定义扩展内置变体的自定义变体。变体允许你为同一个模型配置不同的设置,而无需创建重复的条目: + +```jsonc title="opencode.jsonc" {6-21} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "opencode": { + "models": { + "gpt-5": { + "variants": { + "high": { + "reasoningEffort": "high", + "textVerbosity": "low", + "reasoningSummary": "auto", + }, + "low": { + "reasoningEffort": "low", + "textVerbosity": "low", + "reasoningSummary": "auto", + }, + }, + }, + }, + }, + }, +} +``` + +--- + +## 变体 + +许多模型支持具有不同配置的多种变体。OpenCode 为热门提供商内置了默认变体。 + +### 内置变体 + +OpenCode 为许多提供商提供了默认变体: + +**Anthropic**: + +- `high` - 高思考预算(默认) +- `max` - 最大思考预算 + +**OpenAI**: + +因模型而异,但大致如下: + +- `none` - 无推理 +- `minimal` - 极少推理 +- `low` - 低推理 +- `medium` - 中等推理 +- `high` - 高推理 +- `xhigh` - 超高推理 + +**Google**: + +- `low` - 较低推理/Token 预算 +- `high` - 较高推理/Token 预算 + +:::tip +此列表并不全面,许多其他提供商也有内置的默认变体。 +::: + +### 自定义变体 + +你可以覆盖现有变体或添加自己的变体: + +```jsonc title="opencode.jsonc" {7-18} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openai": { + "models": { + "gpt-5": { + "variants": { + "thinking": { + "reasoningEffort": "high", + "textVerbosity": "low", + }, + "fast": { + "disabled": true, + }, + }, + }, + }, + }, + }, +} +``` + +### 切换变体 + +使用快捷键 `variant_cycle` 可以快速在变体之间切换。[了解更多](/docs/keybinds)。 + +--- + +## 加载模型 + +OpenCode 启动时,会按以下优先顺序加载模型: + +1. `--model` 或 `-m` 命令行标志。格式与配置文件中相同:`provider_id/model_id`。 + +2. OpenCode 配置中的 model 字段。 + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-20250514" + } + ``` + + 格式为 `provider/model`。 + +3. 上次使用的模型。 + +4. 按内部优先级排列的第一个可用模型。 diff --git a/packages/web/src/content/docs/zh-cn/network.mdx b/packages/web/src/content/docs/zh-cn/network.mdx new file mode 100644 index 0000000000000000000000000000000000000000..8289777a1e10d6fe6c1e01405b1459a1e6e1ad6c --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/network.mdx @@ -0,0 +1,57 @@ +--- +title: 网络 +description: 配置代理和自定义证书。 +--- + +OpenCode 支持标准代理环境变量和自定义证书,适用于企业网络环境。 + +--- + +## 代理 + +OpenCode 遵循标准代理环境变量。 + +```bash +# HTTPS proxy (recommended) +export HTTPS_PROXY=https://proxy.example.com:8080 + +# HTTP proxy (if HTTPS not available) +export HTTP_PROXY=http://proxy.example.com:8080 + +# Bypass proxy for local server (required) +export NO_PROXY=localhost,127.0.0.1 +``` + +:::caution +TUI 与本地 HTTP 服务器进行通信。你必须为此连接绕过代理,以防止路由循环。 +::: + +你可以使用 [CLI 标志](/docs/cli#run)来配置服务器的端口和主机名。 + +--- + +### 身份验证 + +如果你的代理需要基本身份验证,请在 URL 中包含凭据。 + +```bash +export HTTPS_PROXY=http://username:password@proxy.example.com:8080 +``` + +:::caution +避免将密码硬编码在代码中。请使用环境变量或安全的凭据存储方式。 +::: + +对于需要高级身份验证(如 NTLM 或 Kerberos)的代理,建议使用支持相应身份验证方式的 LLM 网关。 + +--- + +## 自定义证书 + +如果你的企业使用自定义 CA 进行 HTTPS 连接,请配置 OpenCode 以信任这些证书。 + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/ca-cert.pem +``` + +此配置同时适用于代理连接和直接 API 访问。 diff --git a/packages/web/src/content/docs/zh-cn/permissions.mdx b/packages/web/src/content/docs/zh-cn/permissions.mdx new file mode 100644 index 0000000000000000000000000000000000000000..7f905ac22e97ac79f7c47442d5c5f5d285b513f9 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/permissions.mdx @@ -0,0 +1,235 @@ +--- +title: 权限 +description: 控制哪些操作需要审批才能运行。 +--- + +OpenCode 使用 `permission` 配置来决定某个操作是否应自动运行、提示你审批,还是被阻止。 + +从 `v1.1.1` 开始,旧版 `tools` 布尔配置已被弃用,并已合并到 `permission` 中。旧版 `tools` 配置仍然支持,以保持向后兼容。 + +--- + +## 操作 + +每条权限规则解析为以下之一: + +- `"allow"` — 无需审批直接运行 +- `"ask"` — 提示审批 +- `"deny"` — 阻止该操作 + +--- + +## 配置 + +你可以全局设置权限(使用 `*`),并覆盖特定工具的权限。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "*": "ask", + "bash": "allow", + "edit": "deny" + } +} +``` + +你还可以一次性设置所有权限: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": "allow" +} +``` + +--- + +## 细粒度规则(对象语法) + +对于大多数权限,你可以使用对象来根据工具输入应用不同的操作。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "npm *": "allow", + "rm *": "deny", + "grep *": "allow" + }, + "edit": { + "*": "deny", + "packages/web/src/content/docs/*.mdx": "allow" + } + } +} +``` + +规则通过模式匹配进行评估,**最后匹配的规则优先**。常见做法是将通配的 `"*"` 规则放在最前面,更具体的规则放在后面。 + +### 通配符 + +权限模式使用简单的通配符匹配: + +- `*` 匹配零个或多个任意字符 +- `?` 精确匹配一个字符 +- 所有其他字符按字面值匹配 + +### 主目录展开 + +你可以在模式开头使用 `~` 或 `$HOME` 来引用你的主目录。这对于 [`external_directory`](#外部目录) 规则特别有用。 + +- `~/projects/*` -> `/Users/username/projects/*` +- `$HOME/projects/*` -> `/Users/username/projects/*` +- `~` -> `/Users/username` + +### 外部目录 + +使用 `external_directory` 允许工具调用访问 OpenCode 启动时工作目录之外的路径。这适用于任何接受路径作为输入的工具(例如 `read`、`edit`、`glob`、`grep` 以及许多 `bash` 命令)。 + +主目录展开(如 `~/...`)仅影响模式的书写方式。它不会将外部路径纳入当前工作空间,因此工作目录之外的路径仍然必须通过 `external_directory` 来允许。 + +例如,以下配置允许访问 `~/projects/personal/` 下的所有内容: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "~/projects/personal/**": "allow" + } + } +} +``` + +此处允许的任何目录都会继承与当前工作空间相同的默认值。由于 [`read` 默认为 `allow`](#默认值),`external_directory` 下的条目也允许读取,除非另行覆盖。当需要在这些路径中限制某个工具时,请添加显式规则,例如在保留读取的同时阻止编辑: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "~/projects/personal/**": "allow" + }, + "edit": { + "~/projects/personal/**": "deny" + } + } +} +``` + +请将列表限定在受信任的路径上,并根据需要为其他工具(例如 `bash`)叠加额外的允许或拒绝规则。 + +--- + +## 可用权限 + +OpenCode 的权限以工具名称为键,外加几个安全防护项: + +- `read` — 读取文件(匹配文件路径) +- `edit` — 所有文件修改(涵盖 `edit`、`write`、`patch`) +- `glob` — 文件通配(匹配通配模式) +- `grep` — 内容搜索(匹配正则表达式模式) +- `bash` — 运行 shell 命令(匹配解析后的命令,如 `git status --porcelain`) +- `task` — 启动子代理(匹配子代理类型) +- `skill` — 加载技能(匹配技能名称) +- `lsp` — 运行 LSP 查询(当前不支持细粒度配置) +- `webfetch` — 获取 URL(匹配 URL) +- `websearch` — 网页搜索(匹配查询内容) +- `external_directory` — 当工具访问项目工作目录之外的路径时触发 +- `doom_loop` — 当同一工具调用以相同输入重复 3 次时触发 + +--- + +## 默认值 + +如果你未指定任何配置,OpenCode 将使用宽松的默认值: + +- 大多数权限默认为 `"allow"`。 +- `doom_loop` 和 `external_directory` 默认为 `"ask"`。 +- `read` 为 `"allow"`,但 `.env` 文件默认被拒绝: + +```json title="opencode.json" +{ + "permission": { + "read": { + "*": "allow", + "*.env": "deny", + "*.env.*": "deny", + "*.env.example": "allow" + } + } +} +``` + +--- + +## "Ask"的作用 + +当 OpenCode 提示审批时,界面提供三种选择: + +- `once` — 仅批准本次请求 +- `always` — 批准与建议模式匹配的后续请求(在当前 OpenCode 会话的剩余时间内有效) +- `reject` — 拒绝请求 + +`always` 所批准的模式集合由工具提供(例如,bash 审批通常会将安全的命令前缀如 `git status*` 加入白名单)。 + +--- + +## 代理 + +你可以为每个代理单独覆盖权限。代理权限会与全局配置合并,且代理规则优先。[了解更多](/docs/agents#permissions)关于代理权限的内容。 + +:::note +有关更详细的模式匹配示例,请参阅上方的[细粒度规则(对象语法)](#细粒度规则对象语法)部分。 +::: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "git commit *": "deny", + "git push *": "deny", + "grep *": "allow" + } + }, + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "git commit *": "ask", + "git push *": "deny", + "grep *": "allow" + } + } + } + } +} +``` + +你还可以在 Markdown 中配置代理权限: + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Code review without edits +mode: subagent +permission: + edit: deny + bash: ask + webfetch: deny +--- + +Only analyze code and suggest changes. +``` + +:::tip +对带参数的命令使用模式匹配。`"grep *"` 允许执行 `grep pattern file.txt`,而单独的 `"grep"` 则会阻止它。像 `git status` 这样的命令适用于默认行为,但在传递参数时需要显式权限(如 `"git status *"`)。 +::: diff --git a/packages/web/src/content/docs/zh-cn/plugins.mdx b/packages/web/src/content/docs/zh-cn/plugins.mdx new file mode 100644 index 0000000000000000000000000000000000000000..e8a8bd70cbc3d2961c5f76202b0fa1829ded26cb --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/plugins.mdx @@ -0,0 +1,388 @@ +--- +title: 插件 +description: 编写自己的插件来扩展 OpenCode。 +--- + +插件允许你通过挂钩各种事件和自定义行为来扩展 OpenCode。你可以创建插件来添加新功能、集成外部服务,或修改 OpenCode 的默认行为。 + +如需了解示例,请查看社区创建的[插件](/docs/ecosystem#plugins)。 + +--- + +## 使用插件 + +有两种方式加载插件。 + +--- + +### 从本地文件加载 + +将 JavaScript 或 TypeScript 文件放置在插件目录中。 + +- `.opencode/plugins/` - 项目级插件 +- `~/.config/opencode/plugins/` - 全局插件 + +这些目录中的文件会在启动时自动加载。 + +--- + +### 从 npm 加载 + +在配置文件中指定 npm 包。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-helicone-session", "opencode-wakatime", "@my-org/custom-plugin"] +} +``` + +支持常规和带作用域的 npm 包。 + +浏览[生态系统](/docs/ecosystem#plugins)中的可用插件。 + +--- + +### 插件的安装方式 + +**npm 插件**在启动时使用 Bun 自动安装。包及其依赖项会缓存在 `~/.cache/opencode/node_modules/` 中。 + +**本地插件**直接从插件目录加载。如果需要使用外部包,你必须在配置目录中创建 `package.json`(参见[依赖项](#dependencies)),或者将插件发布到 npm 并[将其添加到配置中](/docs/config#plugins)。 + +--- + +### 加载顺序 + +插件从所有来源加载,所有钩子按顺序执行。加载顺序为: + +1. 全局配置 (`~/.config/opencode/opencode.json`) +2. 项目配置 (`opencode.json`) +3. 全局插件目录 (`~/.config/opencode/plugins/`) +4. 项目插件目录 (`.opencode/plugins/`) + +名称和版本相同的重复 npm 包只会加载一次。但本地插件和名称相似的 npm 插件会分别独立加载。 + +--- + +## 创建插件 + +插件是一个 **JavaScript/TypeScript 模块**,它导出一个或多个插件函数。每个函数接收一个上下文对象,并返回一个钩子对象。 + +--- + +### 依赖项 + +本地插件和自定义工具可以使用外部 npm 包。在配置目录中添加一个 `package.json`,列出所需的依赖项。 + +```json title=".opencode/package.json" +{ + "dependencies": { + "shescape": "^2.1.0" + } +} +``` + +OpenCode 会在启动时运行 `bun install` 来安装这些依赖项。之后你的插件和工具就可以导入它们了。 + +```ts title=".opencode/plugins/my-plugin.ts" +import { escape } from "shescape" + +export const MyPlugin = async (ctx) => { + return { + "tool.execute.before": async (input, output) => { + if (input.tool === "bash") { + output.args.command = escape(output.args.command) + } + }, + } +} +``` + +--- + +### 基本结构 + +```js title=".opencode/plugins/example.js" +export const MyPlugin = async ({ project, client, $, directory, worktree }) => { + console.log("Plugin initialized!") + + return { + // Hook implementations go here + } +} +``` + +插件函数接收以下参数: + +- `project`:当前项目信息。 +- `directory`:当前工作目录。 +- `worktree`:git 工作树路径。 +- `client`:用于与 AI 交互的 OpenCode SDK 客户端。 +- `$`:Bun 的 [Shell API](https://bun.com/docs/runtime/shell),用于执行命令。 + +--- + +### TypeScript 支持 + +对于 TypeScript 插件,你可以从插件包中导入类型: + +```ts title="my-plugin.ts" {1} +import type { Plugin } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async ({ project, client, $, directory, worktree }) => { + return { + // Type-safe hook implementations + } +} +``` + +--- + +### 事件 + +插件可以订阅事件,如下方示例部分所示。以下是所有可用事件的列表。 + +#### 命令事件 + +- `command.executed` + +#### 文件事件 + +- `file.edited` +- `file.watcher.updated` + +#### 安装事件 + +- `installation.updated` + +#### LSP 事件 + +- `lsp.client.diagnostics` +- `lsp.updated` + +#### 消息事件 + +- `message.part.removed` +- `message.part.updated` +- `message.removed` +- `message.updated` + +#### 权限事件 + +- `permission.asked` +- `permission.replied` + +#### 服务器事件 + +- `server.connected` + +#### 会话事件 + +- `session.created` +- `session.compacted` +- `session.deleted` +- `session.diff` +- `session.error` +- `session.idle` +- `session.status` +- `session.updated` + +#### 待办事项事件 + +- `todo.updated` + +#### Shell 事件 + +- `shell.env` + +#### 工具事件 + +- `tool.execute.after` +- `tool.execute.before` + +#### TUI 事件 + +- `tui.prompt.append` +- `tui.command.execute` +- `tui.toast.show` + +--- + +## 示例 + +以下是一些可用于扩展 OpenCode 的插件示例。 + +--- + +### 发送通知 + +在特定事件发生时发送通知: + +```js title=".opencode/plugins/notification.js" +export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { + return { + event: async ({ event }) => { + // Send notification on session completion + if (event.type === "session.idle") { + await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + } + }, + } +} +``` + +这里使用 `osascript` 在 macOS 上运行 AppleScript 来发送通知。 + +:::note +如果你使用 OpenCode 桌面应用,它可以在响应就绪或会话出错时自动发送系统通知。 +::: + +--- + +### .env 保护 + +阻止 OpenCode 读取 `.env` 文件: + +```javascript title=".opencode/plugins/env-protection.js" +export const EnvProtection = async ({ project, client, $, directory, worktree }) => { + return { + "tool.execute.before": async (input, output) => { + if (input.tool === "read" && output.args.filePath.includes(".env")) { + throw new Error("Do not read .env files") + } + }, + } +} +``` + +--- + +### 注入环境变量 + +将环境变量注入所有 Shell 执行(AI 工具和用户终端): + +```javascript title=".opencode/plugins/inject-env.js" +export const InjectEnvPlugin = async () => { + return { + "shell.env": async (input, output) => { + output.env.MY_API_KEY = "secret" + output.env.PROJECT_ROOT = input.cwd + }, + } +} +``` + +--- + +### 自定义工具 + +插件还可以为 OpenCode 添加自定义工具: + +```ts title=".opencode/plugins/custom-tools.ts" +import { type Plugin, tool } from "@opencode-ai/plugin" + +export const CustomToolsPlugin: Plugin = async (ctx) => { + return { + tool: { + mytool: tool({ + description: "This is a custom tool", + args: { + foo: tool.schema.string(), + }, + async execute(args, context) { + const { directory, worktree } = context + return `Hello ${args.foo} from ${directory} (worktree: ${worktree})` + }, + }), + }, + } +} +``` + +`tool` 辅助函数用于创建 OpenCode 可调用的自定义工具。它接受一个 Zod schema 函数,并返回一个工具定义,包含: + +- `description`:工具的功能描述 +- `args`:工具参数的 Zod schema +- `execute`:工具被调用时执行的函数 + +你的自定义工具将与内置工具一起在 OpenCode 中可用。 + +:::note +如果插件工具与内置工具使用相同的名称,则优先使用插件工具。 +::: + +--- + +### 日志记录 + +使用 `client.app.log()` 代替 `console.log` 进行结构化日志记录: + +```ts title=".opencode/plugins/my-plugin.ts" +export const MyPlugin = async ({ client }) => { + await client.app.log({ + body: { + service: "my-plugin", + level: "info", + message: "Plugin initialized", + extra: { foo: "bar" }, + }, + }) +} +``` + +日志级别:`debug`、`info`、`warn`、`error`。详情请参阅 [SDK 文档](https://opencode.ai/docs/sdk)。 + +--- + +### 压缩钩子 + +自定义会话压缩时包含的上下文: + +```ts title=".opencode/plugins/compaction.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const CompactionPlugin: Plugin = async (ctx) => { + return { + "experimental.session.compacting": async (input, output) => { + // Inject additional context into the compaction prompt + output.context.push(` +## Custom Context + +Include any state that should persist across compaction: +- Current task status +- Important decisions made +- Files being actively worked on +`) + }, + } +} +``` + +`experimental.session.compacting` 钩子在 LLM 生成续接摘要之前触发。使用它来注入默认压缩提示词可能遗漏的领域特定上下文。 + +你还可以通过设置 `output.prompt` 来完全替换压缩提示词: + +```ts title=".opencode/plugins/custom-compaction.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const CustomCompactionPlugin: Plugin = async (ctx) => { + return { + "experimental.session.compacting": async (input, output) => { + // Replace the entire compaction prompt + output.prompt = ` +You are generating a continuation prompt for a multi-agent swarm session. + +Summarize: +1. The current task and its status +2. Which files are being modified and by whom +3. Any blockers or dependencies between agents +4. The next steps to complete the work + +Format as a structured prompt that a new agent can use to resume work. +` + }, + } +} +``` + +当设置了 `output.prompt` 时,它会完全替换默认的压缩提示词。在这种情况下,`output.context` 数组将被忽略。 diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx new file mode 100644 index 0000000000000000000000000000000000000000..9c0a5d8a3bd525672904277fae3be5f3d455fbfb --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -0,0 +1,1949 @@ +--- +title: 提供商 +description: 在 OpenCode 中使用任意 LLM 提供商。 +--- + +import config from "../../../../config.mjs" +export const console = config.console + +OpenCode 使用 [AI SDK](https://ai-sdk.dev/) 和 [Models.dev](https://models.dev),支持 **75+ LLM 提供商**,同时也支持运行本地模型。 + +要添加提供商,你需要: + +1. 使用 `/connect` 命令添加提供商的 API 密钥。 +2. 在 OpenCode 配置中设置该提供商。 + +--- + +### 凭据 + +使用 `/connect` 命令添加提供商的 API 密钥后,凭据会存储在 +`~/.local/share/opencode/auth.json` 中。 + +--- + +### 配置 + +你可以通过 OpenCode 配置中的 `provider` 部分来自定义提供商。 + +--- + +#### 自定义 Base URL + +你可以通过设置 `baseURL` 选项来自定义任何提供商的 Base URL。这在使用代理服务或自定义端点时非常有用。 + +```json title="opencode.json" {6} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "baseURL": "https://api.anthropic.com/v1" + } + } + } +} +``` + +--- + +## OpenCode Zen + +OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过测试和验证,能够与 OpenCode 良好配合使用。[了解更多](/docs/zen)。 + +:::tip +如果你是新用户,我们建议从 OpenCode Zen 开始。 +::: + +1. 在 TUI 中执行 `/connect` 命令,选择 opencode,然后前往 [opencode.ai/auth](https://opencode.ai/auth)。 + + ```txt + /connect + ``` + +2. 登录后添加账单信息,然后复制你的 API 密钥。 + +3. 粘贴你的 API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 在 TUI 中执行 `/models` 查看我们推荐的模型列表。 + + ```txt + /models + ``` + +它的使用方式与 OpenCode 中的其他提供商完全相同,且完全可选。 + +--- + +## 目录 + +下面我们来详细了解一些提供商。如果你想将某个提供商添加到列表中,欢迎提交 PR。 + +:::note +没有看到你想要的提供商?欢迎提交 PR。 +::: + +--- + +### 302.AI + +1. 前往 [302.AI 控制台](https://302.ai/),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **302.AI**。 + + ```txt + /connect + ``` + +3. 输入你的 302.AI API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型。 + + ```txt + /models + ``` + +--- + +### Amazon Bedrock + +要在 OpenCode 中使用 Amazon Bedrock: + +1. 前往 Amazon Bedrock 控制台中的**模型目录**,申请访问你想要使用的模型。 + + :::tip + 你需要先在 Amazon Bedrock 中获得对目标模型的访问权限。 + ::: + +2. 使用以下方法之一**配置身份验证**: + + *** + + #### 环境变量(快速上手) + + 运行 opencode 时设置以下环境变量之一: + + ```bash + # Option 1: Using AWS access keys + AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=YYY opencode + + # Option 2: Using named AWS profile + AWS_PROFILE=my-profile opencode + + # Option 3: Using Bedrock bearer token + AWS_BEARER_TOKEN_BEDROCK=XXX opencode + ``` + + 或者将它们添加到你的 bash 配置文件中: + + ```bash title="~/.bash_profile" + export AWS_PROFILE=my-dev-profile + export AWS_REGION=us-east-1 + ``` + + *** + + #### 配置文件(推荐) + + 如需项目级别或持久化的配置,请使用 `opencode.json`: + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "my-aws-profile" + } + } + } + } + ``` + + **可用选项:** + - `region` - AWS 区域(例如 `us-east-1`、`eu-west-1`) + - `profile` - `~/.aws/credentials` 中的 AWS 命名配置文件 + - `endpoint` - VPC 端点的自定义端点 URL(通用 `baseURL` 选项的别名) + + :::tip + 配置文件中的选项优先级高于环境变量。 + ::: + + *** + + #### 进阶:VPC 端点 + + 如果你使用 Bedrock 的 VPC 端点: + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "production", + "endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" + } + } + } + } + ``` + + :::note + `endpoint` 选项是通用 `baseURL` 选项的别名,使用了 AWS 特有的术语。如果同时指定了 `endpoint` 和 `baseURL`,则 `endpoint` 优先。 + ::: + + *** + + #### 认证方式 + - **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**:在 AWS 控制台中创建 IAM 用户并生成访问密钥 + - **`AWS_PROFILE`**:使用 `~/.aws/credentials` 中的命名配置文件。需要先通过 `aws configure --profile my-profile` 或 `aws sso login` 进行配置 + - **`AWS_BEARER_TOKEN_BEDROCK`**:从 Amazon Bedrock 控制台生成长期 API 密钥 + - **`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`**:适用于 EKS IRSA(服务账户的 IAM 角色)或其他支持 OIDC 联合的 Kubernetes 环境。使用服务账户注解时,Kubernetes 会自动注入这些环境变量。 + + *** + + #### 认证优先级 + + Amazon Bedrock 使用以下认证优先级: + 1. **Bearer Token** - `AWS_BEARER_TOKEN_BEDROCK` 环境变量或通过 `/connect` 命令获取的 Token + 2. **AWS 凭证链** - 配置文件、访问密钥、共享凭证、IAM 角色、Web Identity Token(EKS IRSA)、实例元数据 + + :::note + 当设置了 Bearer Token(通过 `/connect` 或 `AWS_BEARER_TOKEN_BEDROCK`)时,它的优先级高于所有 AWS 凭证方式,包括已配置的配置文件。 + ::: + +3. 执行 `/models` 命令选择你想要的模型。 + + ```txt + /models + ``` + +:::note +对于自定义推理配置文件,请在 key 中使用模型名称和提供商名称,并将 `id` 属性设置为 ARN。这可以确保正确的缓存行为: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + // ... + "models": { + "anthropic-claude-sonnet-4.5": { + "id": "arn:aws:bedrock:us-east-1:xxx:application-inference-profile/yyy" + } + } + } + } +} +``` + +::: + +--- + +### Anthropic + +1. 注册完成后,执行 `/connect` 命令并选择 Anthropic。 + + ```txt + /connect + ``` + +2. 你可以选择 **Claude Pro/Max** 选项,浏览器会自动打开并要求你进行身份验证。 + + ```txt + ┌ Select auth method + │ + │ Claude Pro/Max + │ Create an API Key + │ Manually enter API Key + └ + ``` + +3. 现在使用 `/models` 命令即可看到所有 Anthropic 模型。 + + ```txt + /models + ``` + +:::info +在 OpenCode 中使用 Claude Pro/Max 订阅不是 [Anthropic](https://anthropic.com) 官方支持的用法。 +::: + +##### 使用 API 密钥 + +如果你没有 Pro/Max 订阅,也可以选择 **Create an API Key**。浏览器会自动打开并要求你登录 Anthropic,然后会提供一个代码供你粘贴到终端中。 + +如果你已经有 API 密钥,可以选择 **Manually enter API Key** 并将其粘贴到终端中。 + +--- + +### Atomic Chat + +你可以通过 [Atomic Chat](https://atomic.chat) 配置 opencode 以使用本地模型。Atomic Chat 是一款桌面应用程序,它在 OpenAI 兼容的 API 服务器后面运行本地 LLM(默认端点 `http://127.0.0.1:1337/v1`)。 + +```json title="opencode.json" "atomic-chat" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "atomic-chat": { + "npm": "@ai-sdk/openai-compatible", + "name": "Atomic Chat (local)", + "options": { + "baseURL": "http://127.0.0.1:1337/v1" + }, + "models": { + "": { + "name": "" + } + } + } + } +} +``` + +在此示例中: + +- `atomic-chat` 是自定义的提供商 ID。可以是任何你想要的字符串。 +- `npm` 指定此提供商使用的包。这里使用 `@ai-sdk/openai-compatible` 来连接任何 OpenAI 兼容的 API。 +- `name` 是提供商在界面中显示的名称。 +- `options.baseURL` 是本地服务器的端点。根据你的 Atomic Chat 设置修改主机和端口。 +- `models` 是模型 ID 到其显示名称的映射。每个 ID 必须与 `GET /v1/models` 返回的 `id` 匹配——运行 `curl http://127.0.0.1:1337/v1/models` 可列出 Atomic Chat 当前已加载的 ID。 + +:::tip +如果工具调用工作不佳,请选择一个对 tool calling 支持较好的已加载模型(例如 Qwen-Coder 或 DeepSeek-Coder 的变体)。 +::: + +--- + +### Azure OpenAI + +:::note +如果遇到 "I'm sorry, but I cannot assist with that request" 错误,请尝试将 Azure 资源中的内容过滤器从 **DefaultV2** 更改为 **Default**。 +::: + +1. 前往 [Azure 门户](https://portal.azure.com/)并创建 **Azure OpenAI** 资源。你需要: + - **资源名称**:这会成为你的 API 端点的一部分(`https://RESOURCE_NAME.openai.azure.com/`) + - **API 密钥**:资源中的 `KEY 1` 或 `KEY 2` + +2. 前往 [Azure AI Foundry](https://ai.azure.com/) 并部署一个模型。 + + :::note + 部署名称必须与模型名称一致,OpenCode 才能正常工作。 + ::: + +3. 执行 `/connect` 命令并搜索 **Azure**。 + + ```txt + /connect + ``` + +4. 输入你的 API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +5. 将资源名称设置为环境变量: + + ```bash + AZURE_RESOURCE_NAME=XXX opencode + ``` + + 或者添加到你的 bash 配置文件中: + + ```bash title="~/.bash_profile" + export AZURE_RESOURCE_NAME=XXX + ``` + +6. 执行 `/models` 命令选择你已部署的模型。 + + ```txt + /models + ``` + +--- + +### Azure Cognitive Services + +1. 前往 [Azure 门户](https://portal.azure.com/)并创建 **Azure OpenAI** 资源。你需要: + - **资源名称**:这会成为你的 API 端点的一部分(`https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/`) + - **API 密钥**:资源中的 `KEY 1` 或 `KEY 2` + +2. 前往 [Azure AI Foundry](https://ai.azure.com/) 并部署一个模型。 + + :::note + 部署名称必须与模型名称一致,OpenCode 才能正常工作。 + ::: + +3. 执行 `/connect` 命令并搜索 **Azure Cognitive Services**。 + + ```txt + /connect + ``` + +4. 输入你的 API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +5. 将资源名称设置为环境变量: + + ```bash + AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX opencode + ``` + + 或者添加到你的 bash 配置文件中: + + ```bash title="~/.bash_profile" + export AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX + ``` + +6. 执行 `/models` 命令选择你已部署的模型。 + + ```txt + /models + ``` + +--- + +### Baseten + +1. 前往 [Baseten](https://app.baseten.co/),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **Baseten**。 + + ```txt + /connect + ``` + +3. 输入你的 Baseten API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型。 + + ```txt + /models + ``` + +--- + +### Cerebras + +1. 前往 [Cerebras 控制台](https://inference.cerebras.ai/),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **Cerebras**。 + + ```txt + /connect + ``` + +3. 输入你的 Cerebras API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Qwen 3 Coder 480B_。 + + ```txt + /models + ``` + +--- + +### Cloudflare AI Gateway + +Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic、Workers AI 等提供商的模型。通过 [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/),你无需为每个提供商单独准备 API 密钥。 + +1. 前往 [Cloudflare 仪表盘](https://dash.cloudflare.com/),导航到 **AI** > **AI Gateway**,创建一个新的网关。 + +2. 将你的 Account ID 和 Gateway ID 设置为环境变量。 + + ```bash title="~/.bash_profile" + export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id + export CLOUDFLARE_GATEWAY_ID=your-gateway-id + ``` + +3. 执行 `/connect` 命令并搜索 **Cloudflare AI Gateway**。 + + ```txt + /connect + ``` + +4. 输入你的 Cloudflare API Token。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + + 或者将其设置为环境变量。 + + ```bash title="~/.bash_profile" + export CLOUDFLARE_API_TOKEN=your-api-token + ``` + +5. 执行 `/models` 命令选择模型。 + + ```txt + /models + ``` + + 你也可以通过 OpenCode 配置添加模型。 + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "cloudflare-ai-gateway": { + "models": { + "openai/gpt-4o": {}, + "anthropic/claude-sonnet-4": {} + } + } + } + } + ``` + +--- + +### Cortecs + +1. 前往 [Cortecs 控制台](https://cortecs.ai/),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **Cortecs**。 + + ```txt + /connect + ``` + +3. 输入你的 Cortecs API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Kimi K2 Instruct_。 + + ```txt + /models + ``` + +--- + +### DeepSeek + +1. 前往 [DeepSeek 控制台](https://platform.deepseek.com/),创建账户并点击 **Create new API key**。 + +2. 执行 `/connect` 命令并搜索 **DeepSeek**。 + + ```txt + /connect + ``` + +3. 输入你的 DeepSeek API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择 DeepSeek 模型,例如 _DeepSeek V4 Pro_。 + + ```txt + /models + ``` + +--- + +### Deep Infra + +1. 前往 [Deep Infra 仪表盘](https://deepinfra.com/dash),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **Deep Infra**。 + + ```txt + /connect + ``` + +3. 输入你的 Deep Infra API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型。 + + ```txt + /models + ``` + +--- + +### FrogBot + +1. 前往 [FrogBot 仪表盘](https://app.frogbot.ai/signup),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **FrogBot**。 + + ```txt + /connect + ``` + +3. 输入你的 FrogBot API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型。 + + ```txt + /models + ``` + +--- + +### Fireworks AI + +1. 前往 [Fireworks AI 控制台](https://app.fireworks.ai/),创建账户并点击 **Create API Key**。 + +2. 执行 `/connect` 命令并搜索 **Fireworks AI**。 + + ```txt + /connect + ``` + +3. 输入你的 Fireworks AI API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Kimi K2 Instruct_。 + + ```txt + /models + ``` + +--- + +### GitLab Duo + +GitLab Duo 通过 GitLab 的 Anthropic 代理提供具有原生工具调用能力的 AI 驱动的代理聊天。 + +1. 执行 `/connect` 命令并选择 GitLab。 + + ```txt + /connect + ``` + +2. 选择你的身份验证方式: + + ```txt + ┌ Select auth method + │ + │ OAuth (Recommended) + │ Personal Access Token + └ + ``` + + #### 使用 OAuth(推荐) + + 选择 **OAuth**,浏览器会自动打开进行授权。 + + #### 使用个人访问令牌 + 1. 前往 [GitLab 用户设置 > Access Tokens](https://gitlab.com/-/user_settings/personal_access_tokens) + 2. 点击 **Add new token** + 3. 名称填写 `OpenCode`,范围选择 `api` + 4. 复制令牌(以 `glpat-` 开头) + 5. 在终端中输入该令牌 + +3. 执行 `/models` 命令查看可用模型。 + + ```txt + /models + ``` + + 提供三个基于 Claude 的模型: + - **duo-chat-haiku-4-5**(默认)- 快速响应,适合简单任务 + - **duo-chat-sonnet-4-5** - 性能均衡,适合大多数工作流 + - **duo-chat-opus-4-5** - 最强大,适合复杂分析 + +:::note +你也可以通过指定 `GITLAB_TOKEN` 环境变量来避免将令牌存储在 OpenCode 的认证存储中。 +::: + +##### 自托管 GitLab + +:::note[合规说明] +OpenCode 会使用一个小模型来执行部分 AI 任务,例如生成会话标题。默认情况下使用由 Zen 托管的 gpt-5-nano。如果你需要让 OpenCode 仅使用你自己的 GitLab 托管实例,请在 `opencode.json` 文件中添加以下内容。同时建议禁用会话共享。 + +```json +{ + "$schema": "https://opencode.ai/config.json", + "small_model": "gitlab/duo-chat-haiku-4-5", + "share": "disabled" +} +``` + +::: + +对于自托管 GitLab 实例: + +```bash +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_TOKEN=glpat-... +``` + +如果你的实例运行了自定义 AI Gateway: + +```bash +GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com +``` + +或者添加到你的 bash 配置文件中: + +```bash title="~/.bash_profile" +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com +export GITLAB_TOKEN=glpat-... +``` + +:::note +你的 GitLab 管理员必须启用以下功能: + +1. 为用户、群组或实例启用 [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) +2. 功能标志(通过 Rails 控制台): + - `agent_platform_claude_code` + - `third_party_agents_enabled` + ::: + +##### 自托管实例的 OAuth + +要在自托管实例上使用 OAuth,你需要创建一个新应用(设置 → 应用),回调 URL 设置为 `http://127.0.0.1:8080/callback`,并选择以下范围: + +- api(代表你访问 API) +- read_user(读取你的个人信息) +- read_repository(允许对仓库进行只读访问) + +然后将应用 ID 导出为环境变量: + +```bash +export GITLAB_OAUTH_CLIENT_ID=your_application_id_here +``` + +更多文档请参阅 [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 主页。 + +##### 配置 + +通过 `opencode.json` 进行自定义配置: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "gitlab": { + "options": { + "instanceUrl": "https://gitlab.com" + } + } + } +} +``` + +##### GitLab API 工具(可选,但强烈推荐) + +要访问 GitLab 工具(合并请求、Issue、流水线、CI/CD 等): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-gitlab-plugin"] +} +``` + +该插件提供全面的 GitLab 仓库管理功能,包括 MR 审查、Issue 跟踪、流水线监控等。 + +--- + +### GitHub Copilot + +要在 OpenCode 中使用你的 GitHub Copilot 订阅: + +:::note +部分模型可能需要 [Pro+ 订阅](https://github.com/features/copilot/plans)才能使用。 +::: + +1. 执行 `/connect` 命令并搜索 GitHub Copilot。 + + ```txt + /connect + ``` + +2. 前往 [github.com/login/device](https://github.com/login/device) 并输入验证码。 + + ```txt + ┌ Login with GitHub Copilot + │ + │ https://github.com/login/device + │ + │ Enter code: 8F43-6FCF + │ + └ Waiting for authorization... + ``` + +3. 现在执行 `/models` 命令选择你想要的模型。 + + ```txt + /models + ``` + +--- + +### Google Vertex AI + +要在 OpenCode 中使用 Google Vertex AI: + +1. 前往 Google Cloud Console 中的**模型花园**,查看你所在区域可用的模型。 + + :::note + 你需要一个启用了 Vertex AI API 的 Google Cloud 项目。 + ::: + +2. 设置所需的环境变量: + - `GOOGLE_CLOUD_PROJECT`:你的 Google Cloud 项目 ID + - `VERTEX_LOCATION`(可选):Vertex AI 的区域(默认为 `global`) + - 身份验证(选择其一): + - `GOOGLE_APPLICATION_CREDENTIALS`:服务账户 JSON 密钥文件的路径 + - 使用 gcloud CLI 进行身份验证:`gcloud auth application-default login` + + 在运行 opencode 时设置: + + ```bash + GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json GOOGLE_CLOUD_PROJECT=your-project-id opencode + ``` + + 或者添加到你的 bash 配置文件中: + + ```bash title="~/.bash_profile" + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json + export GOOGLE_CLOUD_PROJECT=your-project-id + export VERTEX_LOCATION=global + ``` + +:::tip +`global` 区域可以提高可用性并减少错误,且不会产生额外费用。如果有数据驻留需求,请使用区域端点(例如 `us-central1`)。[了解更多](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models#regional_and_global_endpoints) +::: + +3. 执行 `/models` 命令选择你想要的模型。 + + ```txt + /models + ``` + +--- + +### Groq + +1. 前往 [Groq 控制台](https://console.groq.com/),点击 **Create API Key** 并复制密钥。 + +2. 执行 `/connect` 命令并搜索 Groq。 + + ```txt + /connect + ``` + +3. 输入该提供商的 API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择你想要的模型。 + + ```txt + /models + ``` + +--- + +### Hugging Face + +[Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers) 提供对由 17+ 提供商支持的开放模型的访问。 + +1. 前往 [Hugging Face 设置](https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained),创建一个具有调用 Inference Providers 权限的令牌。 + +2. 执行 `/connect` 命令并搜索 **Hugging Face**。 + + ```txt + /connect + ``` + +3. 输入你的 Hugging Face 令牌。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Kimi-K2-Instruct_ 或 _GLM-4.6_。 + + ```txt + /models + ``` + +--- + +### Helicone + +[Helicone](https://helicone.ai) 是一个 LLM 可观测性平台,为你的 AI 应用提供日志记录、监控和分析功能。Helicone AI Gateway 会根据模型自动将请求路由到对应的提供商。 + +1. 前往 [Helicone](https://helicone.ai),创建账户并在仪表盘中生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **Helicone**。 + + ```txt + /connect + ``` + +3. 输入你的 Helicone API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型。 + + ```txt + /models + ``` + +如需了解更多提供商以及缓存、速率限制等高级功能,请查阅 [Helicone 文档](https://docs.helicone.ai)。 + +#### 可选配置 + +如果 Helicone 的某些功能或模型未通过 OpenCode 自动配置,你随时可以手动配置。 + +[Helicone 模型目录](https://helicone.ai/models)中可以找到你需要添加的模型 ID。 + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + }, + "models": { + "gpt-4o": { + // Model ID (from Helicone's model directory page) + "name": "GPT-4o", // Your own custom name for the model + }, + "claude-sonnet-4-20250514": { + "name": "Claude Sonnet 4", + }, + }, + }, + }, +} +``` + +#### 自定义请求头 + +Helicone 支持用于缓存、用户跟踪和会话管理等功能的自定义请求头。使用 `options.headers` 将它们添加到提供商配置中: + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + "headers": { + "Helicone-Cache-Enabled": "true", + "Helicone-User-Id": "opencode", + }, + }, + }, + }, +} +``` + +##### 会话跟踪 + +Helicone 的 [Sessions](https://docs.helicone.ai/features/sessions) 功能允许你将相关的 LLM 请求归为一组。使用 [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) 插件可以自动将每个 OpenCode 对话记录为 Helicone 中的一个会话。 + +```bash +npm install -g opencode-helicone-session +``` + +将其添加到配置中。 + +```json title="opencode.json" +{ + "plugin": ["opencode-helicone-session"] +} +``` + +该插件会在你的请求中注入 `Helicone-Session-Id` 和 `Helicone-Session-Name` 请求头。在 Helicone 的 Sessions 页面中,你可以看到每个 OpenCode 对话都作为独立的会话列出。 + +##### 常用 Helicone 请求头 + +| 请求头 | 描述 | +| -------------------------- | ------------------------------------------------------ | +| `Helicone-Cache-Enabled` | 启用响应缓存(`true`/`false`) | +| `Helicone-User-Id` | 按用户跟踪指标 | +| `Helicone-Property-[Name]` | 添加自定义属性(例如 `Helicone-Property-Environment`) | +| `Helicone-Prompt-Id` | 将请求与提示词版本关联 | + +有关所有可用请求头,请参阅 [Helicone Header Directory](https://docs.helicone.ai/helicone-headers/header-directory)。 + +--- + +### llama.cpp + +你可以通过 [llama.cpp](https://github.com/ggml-org/llama.cpp) 的 llama-server 工具配置 OpenCode 使用本地模型。 + +```json title="opencode.json" "llama.cpp" {5, 6, 8, 10-15} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "llama.cpp": { + "npm": "@ai-sdk/openai-compatible", + "name": "llama-server (local)", + "options": { + "baseURL": "http://127.0.0.1:8080/v1" + }, + "models": { + "qwen3-coder:a3b": { + "name": "Qwen3-Coder: a3b-30b (local)", + "limit": { + "context": 128000, + "output": 65536 + } + } + } + } + } +} +``` + +在这个示例中: + +- `llama.cpp` 是自定义的提供商 ID,可以是任意字符串。 +- `npm` 指定该提供商使用的包。这里使用 `@ai-sdk/openai-compatible` 来兼容任何 OpenAI 兼容的 API。 +- `name` 是该提供商在 UI 中显示的名称。 +- `options.baseURL` 是本地服务器的端点地址。 +- `models` 是模型 ID 到其配置的映射。模型名称会显示在模型选择列表中。 + +--- + +### IO.NET + +IO.NET 提供 17 个针对不同用例优化的模型: + +1. 前往 [IO.NET 控制台](https://ai.io.net/),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **IO.NET**。 + + ```txt + /connect + ``` + +3. 输入你的 IO.NET API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型。 + + ```txt + /models + ``` + +--- + +### LM Studio + +你可以通过 LM Studio 配置 OpenCode 使用本地模型。 + +```json title="opencode.json" "lmstudio" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "lmstudio": { + "npm": "@ai-sdk/openai-compatible", + "name": "LM Studio (local)", + "options": { + "baseURL": "http://127.0.0.1:1234/v1" + }, + "models": { + "google/gemma-3n-e4b": { + "name": "Gemma 3n-e4b (local)" + } + } + } + } +} +``` + +在这个示例中: + +- `lmstudio` 是自定义的提供商 ID,可以是任意字符串。 +- `npm` 指定该提供商使用的包。这里使用 `@ai-sdk/openai-compatible` 来兼容任何 OpenAI 兼容的 API。 +- `name` 是该提供商在 UI 中显示的名称。 +- `options.baseURL` 是本地服务器的端点地址。 +- `models` 是模型 ID 到其配置的映射。模型名称会显示在模型选择列表中。 + +--- + +### Moonshot AI + +要使用 Moonshot AI 的 Kimi K2: + +1. 前往 [Moonshot AI 控制台](https://platform.moonshot.ai/console),创建账户并点击 **Create API key**。 + +2. 执行 `/connect` 命令并搜索 **Moonshot AI**。 + + ```txt + /connect + ``` + +3. 输入你的 Moonshot API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择 _Kimi K2_。 + + ```txt + /models + ``` + +--- + +### MiniMax + +1. 前往 [MiniMax API 控制台](https://platform.minimax.io/login),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **MiniMax**。 + + ```txt + /connect + ``` + +3. 输入你的 MiniMax API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _M2.1_。 + + ```txt + /models + ``` + +--- + +### Nebius Token Factory + +1. 前往 [Nebius Token Factory 控制台](https://tokenfactory.nebius.com/),创建账户并点击 **Add Key**。 + +2. 执行 `/connect` 命令并搜索 **Nebius Token Factory**。 + + ```txt + /connect + ``` + +3. 输入你的 Nebius Token Factory API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Kimi K2 Instruct_。 + + ```txt + /models + ``` + +--- + +### Ollama + +你可以通过 Ollama 配置 OpenCode 使用本地模型。 + +:::tip +Ollama 可以自动为 OpenCode 进行配置。详见 [Ollama 集成文档](https://docs.ollama.com/integrations/opencode)。 +::: + +```json title="opencode.json" "ollama" {5, 6, 8, 10-14} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "ollama": { + "npm": "@ai-sdk/openai-compatible", + "name": "Ollama (local)", + "options": { + "baseURL": "http://localhost:11434/v1" + }, + "models": { + "llama2": { + "name": "Llama 2" + } + } + } + } +} +``` + +在这个示例中: + +- `ollama` 是自定义的提供商 ID,可以是任意字符串。 +- `npm` 指定该提供商使用的包。这里使用 `@ai-sdk/openai-compatible` 来兼容任何 OpenAI 兼容的 API。 +- `name` 是该提供商在 UI 中显示的名称。 +- `options.baseURL` 是本地服务器的端点地址。 +- `models` 是模型 ID 到其配置的映射。模型名称会显示在模型选择列表中。 + +:::tip +如果工具调用不工作,请尝试增大 Ollama 中的 `num_ctx` 值。建议从 16k - 32k 左右开始。 +::: + +--- + +### Ollama Cloud + +要在 OpenCode 中使用 Ollama Cloud: + +1. 前往 [https://ollama.com/](https://ollama.com/) 登录或创建账户。 + +2. 导航到 **Settings** > **Keys**,点击 **Add API Key** 生成新的 API 密钥。 + +3. 复制 API 密钥以便在 OpenCode 中使用。 + +4. 执行 `/connect` 命令并搜索 **Ollama Cloud**。 + + ```txt + /connect + ``` + +5. 输入你的 Ollama Cloud API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +6. **重要**:在 OpenCode 中使用云端模型之前,必须先将模型信息拉取到本地: + + ```bash + ollama pull gpt-oss:20b-cloud + ``` + +7. 执行 `/models` 命令选择你的 Ollama Cloud 模型。 + + ```txt + /models + ``` + +--- + +### OpenAI + +我们建议注册 [ChatGPT Plus 或 Pro](https://chatgpt.com/pricing)。 + +1. 注册完成后,执行 `/connect` 命令并选择 OpenAI。 + + ```txt + /connect + ``` + +2. 你可以选择 **ChatGPT Plus/Pro** 选项,浏览器会自动打开并要求你进行身份验证。 + + ```txt + ┌ Select auth method + │ + │ ChatGPT Plus/Pro + │ Manually enter API Key + └ + ``` + +3. 现在使用 `/models` 命令即可看到所有 OpenAI 模型。 + + ```txt + /models + ``` + +##### 使用 API 密钥 + +如果你已经有 API 密钥,可以选择 **Manually enter API Key** 并将其粘贴到终端中。 + +--- + +### OpenCode Zen + +OpenCode Zen 是由 OpenCode 团队提供的经过测试和验证的模型列表。[了解更多](/docs/zen)。 + +1. 登录 **OpenCode Zen** 并点击 **Create API Key**。 + +2. 执行 `/connect` 命令并搜索 **OpenCode Zen**。 + + ```txt + /connect + ``` + +3. 输入你的 OpenCode API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Qwen 3 Coder 480B_。 + + ```txt + /models + ``` + +--- + +### OpenRouter + +1. 前往 [OpenRouter 仪表盘](https://openrouter.ai/settings/keys),点击 **Create API Key** 并复制密钥。 + +2. 执行 `/connect` 命令并搜索 OpenRouter。 + + ```txt + /connect + ``` + +3. 输入该提供商的 API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 默认已预加载了许多 OpenRouter 模型,执行 `/models` 命令选择你想要的模型。 + + ```txt + /models + ``` + + 你也可以通过 OpenCode 配置添加更多模型。 + + ```json title="opencode.json" {6} + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "models": { + "somecoolnewmodel": {} + } + } + } + } + ``` + +5. 你还可以通过 OpenCode 配置自定义模型。以下是指定提供商的示例: + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "models": { + "moonshotai/kimi-k2": { + "options": { + "provider": { + "order": ["baseten"], + "allow_fallbacks": false + } + } + } + } + } + } + } + ``` + +--- + +### SAP AI Core + +SAP AI Core 通过统一平台提供对来自 OpenAI、Anthropic、Google、Amazon、Meta、Mistral 和 AI21 的 40+ 模型的访问。 + +1. 前往 [SAP BTP Cockpit](https://account.hana.ondemand.com/),导航到你的 SAP AI Core 服务实例,并创建服务密钥。 + + :::tip + 服务密钥是一个包含 `clientid`、`clientsecret`、`url` 和 `serviceurls.AI_API_URL` 的 JSON 对象。你可以在 BTP Cockpit 的 **Services** > **Instances and Subscriptions** 下找到你的 AI Core 实例。 + ::: + +2. 执行 `/connect` 命令并搜索 **SAP AI Core**。 + + ```txt + /connect + ``` + +3. 输入你的服务密钥 JSON。 + + ```txt + ┌ Service key + │ + │ + └ enter + ``` + + 或者设置 `AICORE_SERVICE_KEY` 环境变量: + + ```bash + AICORE_SERVICE_KEY='{"clientid":"...","clientsecret":"...","url":"...","serviceurls":{"AI_API_URL":"..."}}' opencode + ``` + + 或者添加到你的 bash 配置文件中: + + ```bash title="~/.bash_profile" + export AICORE_SERVICE_KEY='{"clientid":"...","clientsecret":"...","url":"...","serviceurls":{"AI_API_URL":"..."}}' + ``` + +4. 可选:设置部署 ID 和资源组: + + ```bash + AICORE_DEPLOYMENT_ID=your-deployment-id AICORE_RESOURCE_GROUP=your-resource-group opencode + ``` + + :::note + 这些设置是可选的,应根据你的 SAP AI Core 配置进行设置。 + ::: + +5. 执行 `/models` 命令从 40+ 个可用模型中进行选择。 + + ```txt + /models + ``` + +--- + +### STACKIT + +STACKIT AI Model Serving 提供完全托管的主权托管环境,专注于 Llama、Mistral 和 Qwen 等大语言模型,在欧洲基础设施上实现最大程度的数据主权。 + +1. 前往 [STACKIT Portal](https://portal.stackit.cloud),导航到 **AI Model Serving**,为你的项目创建认证令牌。 + + :::tip + 你需要先拥有 STACKIT 客户账户、用户账户和项目,才能创建认证令牌。 + ::: + +2. 执行 `/connect` 命令并搜索 **STACKIT**。 + + ```txt + /connect + ``` + +3. 输入你的 STACKIT AI Model Serving 认证令牌。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Qwen3-VL 235B_ 或 _Llama 3.3 70B_。 + + ```txt + /models + ``` + +--- + +### OVHcloud AI Endpoints + +1. 前往 [OVHcloud 管理面板](https://ovh.com/manager)。导航到 `Public Cloud` 部分,`AI & Machine Learning` > `AI Endpoints`,在 `API Keys` 标签页中点击 **Create a new API key**。 + +2. 执行 `/connect` 命令并搜索 **OVHcloud AI Endpoints**。 + + ```txt + /connect + ``` + +3. 输入你的 OVHcloud AI Endpoints API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _gpt-oss-120b_。 + + ```txt + /models + ``` + +--- + +### Scaleway + +要在 OpenCode 中使用 [Scaleway Generative APIs](https://www.scaleway.com/en/docs/generative-apis/): + +1. 前往 [Scaleway Console IAM 设置](https://console.scaleway.com/iam/api-keys)生成新的 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **Scaleway**。 + + ```txt + /connect + ``` + +3. 输入你的 Scaleway API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _devstral-2-123b-instruct-2512_ 或 _gpt-oss-120b_。 + + ```txt + /models + ``` + +--- + +### Together AI + +1. 前往 [Together AI 控制台](https://api.together.ai),创建账户并点击 **Add Key**。 + +2. 执行 `/connect` 命令并搜索 **Together AI**。 + + ```txt + /connect + ``` + +3. 输入你的 Together AI API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Kimi K2 Instruct_。 + + ```txt + /models + ``` + +--- + +### Venice AI + +1. 前往 [Venice AI 控制台](https://venice.ai),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **Venice AI**。 + + ```txt + /connect + ``` + +3. 输入你的 Venice AI API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Llama 3.3 70B_。 + + ```txt + /models + ``` + +--- + +### Vercel AI Gateway + +Vercel AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic、Google、xAI 等提供商的模型。模型按原价提供,不额外加价。 + +1. 前往 [Vercel 仪表盘](https://vercel.com/),导航到 **AI Gateway** 标签页,点击 **API keys** 创建新的 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **Vercel AI Gateway**。 + + ```txt + /connect + ``` + +3. 输入你的 Vercel AI Gateway API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型。 + + ```txt + /models + ``` + +你也可以通过 OpenCode 配置自定义模型。以下是指定提供商路由顺序的示例。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "vercel": { + "models": { + "anthropic/claude-sonnet-4": { + "options": { + "order": ["anthropic", "vertex"] + } + } + } + } + } +} +``` + +一些常用的路由选项: + +| 选项 | 描述 | +| ------------------- | -------------------------------- | +| `order` | 提供商尝试顺序 | +| `only` | 限制为特定提供商 | +| `zeroDataRetention` | 仅使用具有零数据留存策略的提供商 | + +--- + +### xAI + +1. 前往 [xAI 控制台](https://console.x.ai/),创建账户并生成 API 密钥。 + +2. 执行 `/connect` 命令并搜索 **xAI**。 + + ```txt + /connect + ``` + +3. 输入你的 xAI API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _Grok Beta_。 + + ```txt + /models + ``` + +--- + +### Z.AI + +1. 前往 [Z.AI API 控制台](https://z.ai/manage-apikey/apikey-list),创建账户并点击 **Create a new API key**。 + +2. 执行 `/connect` 命令并搜索 **Z.AI**。 + + ```txt + /connect + ``` + + 如果你订阅了 **GLM Coding Plan**,请选择 **Z.AI Coding Plan**。 + +3. 输入你的 Z.AI API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 执行 `/models` 命令选择模型,例如 _GLM-4.7_。 + + ```txt + /models + ``` + +--- + +### ZenMux + +1. 前往 [ZenMux 仪表盘](https://zenmux.ai/settings/keys),点击 **Create API Key** 并复制密钥。 + +2. 执行 `/connect` 命令并搜索 ZenMux。 + + ```txt + /connect + ``` + +3. 输入该提供商的 API 密钥。 + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. 默认已预加载了许多 ZenMux 模型,执行 `/models` 命令选择你想要的模型。 + + ```txt + /models + ``` + + 你也可以通过 OpenCode 配置添加更多模型。 + + ```json title="opencode.json" {6} + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "zenmux": { + "models": { + "somecoolnewmodel": {} + } + } + } + } + ``` + +--- + +## 自定义提供商 + +要添加 `/connect` 命令中未列出的任何 **OpenAI 兼容**提供商: + +:::tip +你可以在 OpenCode 中使用任何 OpenAI 兼容的提供商。大多数现代 AI 提供商都提供 OpenAI 兼容的 API。 +::: + +1. 执行 `/connect` 命令,向下滚动到 **Other**。 + + ```bash + $ /connect + + ┌ Add credential + │ + ◆ Select provider + │ ... + │ ● Other + └ + ``` + +2. 输入该提供商的唯一 ID。 + + ```bash + $ /connect + + ┌ Add credential + │ + ◇ Enter provider id + │ myprovider + └ + ``` + + :::note + 请选择一个容易记住的 ID,你将在配置文件中使用它。 + ::: + +3. 输入该提供商的 API 密钥。 + + ```bash + $ /connect + + ┌ Add credential + │ + ▲ This only stores a credential for myprovider - you will need to configure it in opencode.json, check the docs for examples. + │ + ◇ Enter your API key + │ sk-... + └ + ``` + +4. 在项目目录中创建或更新 `opencode.json` 文件: + + ```json title="opencode.json" ""myprovider"" {5-15} + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "myprovider": { + "npm": "@ai-sdk/openai-compatible", + "name": "My AI ProviderDisplay Name", + "options": { + "baseURL": "https://api.myprovider.com/v1" + }, + "models": { + "my-model-name": { + "name": "My Model Display Name" + } + } + } + } + } + ``` + + 以下是配置选项说明: + - **npm**:要使用的 AI SDK 包,对于 OpenAI 兼容的提供商使用 `@ai-sdk/openai-compatible`(适用于 `/v1/chat/completions`)。如果你的提供商/模型走 `/v1/responses`,请使用 `@ai-sdk/openai`。 + - **name**:在 UI 中显示的名称。 + - **models**:可用模型。 + - **options.baseURL**:API 端点 URL。 + - **options.apiKey**:可选,如果不使用 auth 认证,可直接设置 API 密钥。 + - **options.headers**:可选,设置自定义请求头。 + + 更多高级选项请参见下面的示例。 + +5. 执行 `/models` 命令,你自定义的提供商和模型将出现在选择列表中。 + +--- + +##### 示例 + +以下是设置 `apiKey`、`headers` 和模型 `limit` 选项的示例。 + +```json title="opencode.json" {9,11,17-20} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "myprovider": { + "npm": "@ai-sdk/openai-compatible", + "name": "My AI ProviderDisplay Name", + "options": { + "baseURL": "https://api.myprovider.com/v1", + "apiKey": "{env:ANTHROPIC_API_KEY}", + "headers": { + "Authorization": "Bearer custom-token" + } + }, + "models": { + "my-model-name": { + "name": "My Model Display Name", + "limit": { + "context": 200000, + "output": 65536 + } + } + } + } + } +} +``` + +配置详情: + +- **apiKey**:使用 `env` 变量语法设置,[了解更多](/docs/config#env-vars)。 +- **headers**:随每个请求发送的自定义请求头。 +- **limit.context**:模型接受的最大输入 Token 数。 +- **limit.output**:模型可生成的最大 Token 数。 + +`limit` 字段让 OpenCode 了解你还剩余多少上下文空间。标准提供商会自动从 models.dev 拉取这些信息。 + +--- + +## 故障排除 + +如果你在配置提供商时遇到问题,请检查以下几点: + +1. **检查认证设置**:运行 `opencode auth list` 查看该提供商的凭据是否已添加到配置中。 + + 这不适用于 Amazon Bedrock 等依赖环境变量进行认证的提供商。 + +2. 对于自定义提供商,请检查 OpenCode 配置并确认: + - `/connect` 命令中使用的提供商 ID 与 OpenCode 配置中的 ID 一致。 + - 使用了正确的 npm 包。例如,Cerebras 应使用 `@ai-sdk/cerebras`。对于其他所有 OpenAI 兼容的提供商,使用 `@ai-sdk/openai-compatible`(`/v1/chat/completions`);如果模型走 `/v1/responses`,请使用 `@ai-sdk/openai`。同一 provider 混用时,可在模型下设置 `provider.npm` 覆盖默认值。 + - `options.baseURL` 字段中的 API 端点地址正确。 diff --git a/packages/web/src/content/docs/zh-cn/rules.mdx b/packages/web/src/content/docs/zh-cn/rules.mdx new file mode 100644 index 0000000000000000000000000000000000000000..9ce5c53de8bf483fb08c8032f64bfd92dd584b51 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/rules.mdx @@ -0,0 +1,180 @@ +--- +title: 规则 +description: 为 opencode 设置自定义指令。 +--- + +您可以通过创建 `AGENTS.md` 文件来为 opencode 提供自定义指令。这类似于 Cursor 的规则功能。该文件包含的指令会被纳入 LLM 的上下文中,以便针对您的特定项目自定义其行为。 + +--- + +## 初始化 + +要创建新的 `AGENTS.md` 文件,您可以在 opencode 中运行 `/init` 命令。 + +:::tip +您应该将项目的 `AGENTS.md` 文件提交到 Git。 +::: + +该命令会扫描您的项目及其所有内容,了解项目的用途,并据此生成一个 `AGENTS.md` 文件。这有助于 opencode 更好地导航您的项目。 + +如果您已有 `AGENTS.md` 文件,该命令会尝试在其基础上进行补充。 + +--- + +## 示例 + +您也可以手动创建此文件。以下是一些可以放入 `AGENTS.md` 文件中的内容示例。 + +```markdown title="AGENTS.md" +# SST v3 Monorepo Project + +This is an SST v3 monorepo with TypeScript. The project uses bun workspaces for package management. + +## Project Structure + +- `packages/` - Contains all workspace packages (functions, core, web, etc.) +- `infra/` - Infrastructure definitions split by service (storage.ts, api.ts, web.ts) +- `sst.config.ts` - Main SST configuration with dynamic imports + +## Code Standards + +- Use TypeScript with strict mode enabled +- Shared code goes in `packages/core/` with proper exports configuration +- Functions go in `packages/functions/` +- Infrastructure should be split into logical files in `infra/` + +## Monorepo Conventions + +- Import shared modules using workspace names: `@my-app/core/example` +``` + +我们在这里添加了项目特定的指令,这些指令会在您的团队中共享。 + +--- + +## 类型 + +opencode 还支持从多个位置读取 `AGENTS.md` 文件,不同的位置有不同的用途。 + +### 项目级 + +在项目根目录放置一个 `AGENTS.md` 文件,用于定义项目特定的规则。这些规则仅在您在该目录或其子目录中工作时生效。 + +### 全局级 + +您还可以在 `~/.config/opencode/AGENTS.md` 文件中设置全局规则。这些规则会应用于所有 opencode 会话。 + +由于该文件不会被提交到 Git 或与团队共享,我们建议用它来指定 LLM 应遵循的个人规则。 + +### Claude Code 兼容性 + +对于从 Claude Code 迁移过来的用户,OpenCode 支持 Claude Code 的文件约定作为回退方案: + +- **项目规则**:项目目录中的 `CLAUDE.md`(在没有 `AGENTS.md` 的情况下使用) +- **全局规则**:`~/.claude/CLAUDE.md`(在没有 `~/.config/opencode/AGENTS.md` 的情况下使用) +- **技能**:`~/.claude/skills/` — 详情请参阅[代理技能](/docs/skills/) + +要禁用 Claude Code 兼容性,请设置以下环境变量之一: + +```bash +export OPENCODE_DISABLE_CLAUDE_CODE=1 # Disable all .claude support +export OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1 # Disable only ~/.claude/CLAUDE.md +export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills +``` + +--- + +## 优先级 + +当 opencode 启动时,它会按以下顺序查找规则文件: + +1. **本地文件**,从当前目录向上遍历(`AGENTS.md`、`CLAUDE.md`) +2. **全局文件**,位于 `~/.config/opencode/AGENTS.md` +3. **Claude Code 文件**,位于 `~/.claude/CLAUDE.md`(除非已禁用) + +在每个类别中,第一个匹配的文件优先。例如,如果您同时拥有 `AGENTS.md` 和 `CLAUDE.md`,则只会使用 `AGENTS.md`。同样,`~/.config/opencode/AGENTS.md` 优先于 `~/.claude/CLAUDE.md`。 + +--- + +## 自定义指令 + +您可以在 `opencode.json` 或全局配置文件 `~/.config/opencode/opencode.json` 中指定自定义指令文件。这允许您和团队复用现有规则,而无需将它们复制到 AGENTS.md 中。 + +示例: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md"] +} +``` + +您还可以使用远程 URL 从网络加载指令。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["https://raw.githubusercontent.com/my-org/shared-rules/main/style.md"] +} +``` + +远程指令的获取超时时间为 5 秒。 + +所有指令文件都会与您的 `AGENTS.md` 文件合并。 + +--- + +## 引用外部文件 + +虽然 opencode 不会自动解析 `AGENTS.md` 中的文件引用,但您可以通过以下两种方式实现类似的功能: + +### 使用 opencode.json + +推荐的方式是使用 `opencode.json` 中的 `instructions` 字段: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["docs/development-standards.md", "test/testing-guidelines.md", "packages/*/AGENTS.md"] +} +``` + +### 在 AGENTS.md 中手动指定 + +您可以在 `AGENTS.md` 中提供明确的指令,教 opencode 读取外部文件。以下是一个实际示例: + +```markdown title="AGENTS.md" +# TypeScript Project Rules + +## External File Loading + +CRITICAL: When you encounter a file reference (e.g., @rules/general.md), use your Read tool to load it on a need-to-know basis. They're relevant to the SPECIFIC task at hand. + +Instructions: + +- Do NOT preemptively load all references - use lazy loading based on actual need +- When loaded, treat content as mandatory instructions that override defaults +- Follow references recursively when needed + +## Development Guidelines + +For TypeScript code style and best practices: @docs/typescript-guidelines.md +For React component architecture and hooks patterns: @docs/react-patterns.md +For REST API design and error handling: @docs/api-standards.md +For testing strategies and coverage requirements: @test/testing-guidelines.md + +## General Guidelines + +Read the following file immediately as it's relevant to all workflows: @rules/general-guidelines.md. +``` + +这种方式允许您: + +- 创建模块化、可复用的规则文件 +- 通过符号链接或 Git 子模块在项目之间共享规则 +- 保持 AGENTS.md 简洁,同时引用详细的指南 +- 确保 opencode 仅在特定任务需要时才加载文件 + +:::tip +对于 monorepo 或具有共享标准的项目,使用 `opencode.json` 配合 glob 模式(如 `packages/*/AGENTS.md`)比手动指定指令更易于维护。 +::: diff --git a/packages/web/src/content/docs/zh-cn/sdk.mdx b/packages/web/src/content/docs/zh-cn/sdk.mdx new file mode 100644 index 0000000000000000000000000000000000000000..121e423d10d97849f41888810b1cf6ba9799e629 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/sdk.mdx @@ -0,0 +1,463 @@ +--- +title: SDK +description: opencode 服务器的类型安全 JS 客户端。 +--- + +import config from "../../../../config.mjs" +export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` + +opencode JS/TS SDK 提供了一个类型安全的客户端,用于与服务器进行交互。 +你可以用它来构建集成方案,并以编程方式控制 opencode。 + +[了解更多](/docs/server)关于服务器的工作原理。如需示例,请查看社区构建的[项目](/docs/ecosystem#projects)。 + +--- + +## 安装 + +从 npm 安装 SDK: + +```bash +npm install @opencode-ai/sdk +``` + +--- + +## 创建客户端 + +创建一个 opencode 实例: + +```javascript +import { createOpencode } from "@opencode-ai/sdk" + +const { client } = await createOpencode() +``` + +这会同时启动服务器和客户端。 + +#### 选项 + +| 选项 | 类型 | 描述 | 默认值 | +| ---------- | ------------- | -------------------------- | ----------- | +| `hostname` | `string` | 服务器主机名 | `127.0.0.1` | +| `port` | `number` | 服务器端口 | `4096` | +| `signal` | `AbortSignal` | 用于取消操作的中止信号 | `undefined` | +| `timeout` | `number` | 服务器启动超时时间(毫秒) | `5000` | +| `config` | `Config` | 配置对象 | `{}` | + +--- + +## 配置 + +你可以传入一个配置对象来自定义行为。实例仍然会读取你的 `opencode.json`,但你可以通过内联方式覆盖或添加配置: + +```javascript +import { createOpencode } from "@opencode-ai/sdk" + +const opencode = await createOpencode({ + hostname: "127.0.0.1", + port: 4096, + config: { + model: "anthropic/claude-3-5-sonnet-20241022", + }, +}) + +console.log(`Server running at ${opencode.server.url}`) + +opencode.server.close() +``` + +## 仅客户端模式 + +如果你已经有一个正在运行的 opencode 实例,可以创建一个客户端实例来连接它: + +```javascript +import { createOpencodeClient } from "@opencode-ai/sdk" + +const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", +}) +``` + +#### 选项 + +| 选项 | 类型 | 描述 | 默认值 | +| --------------- | ---------- | ---------------------------- | ----------------------- | +| `baseUrl` | `string` | 服务器 URL | `http://localhost:4096` | +| `fetch` | `function` | 自定义 fetch 实现 | `globalThis.fetch` | +| `parseAs` | `string` | 响应解析方式 | `auto` | +| `responseStyle` | `string` | 返回风格:`data` 或 `fields` | `fields` | +| `throwOnError` | `boolean` | 抛出错误而非返回错误 | `false` | + +--- + +## 类型 + +SDK 包含所有 API 类型的 TypeScript 定义。你可以直接导入它们: + +```typescript +import type { Session, Message, Part } from "@opencode-ai/sdk" +``` + +所有类型均根据服务器的 OpenAPI 规范生成,可在类型文件中查看。 + +--- + +## 错误处理 + +SDK 可能会抛出错误,你可以捕获并处理这些错误: + +```typescript +try { + await client.session.get({ path: { id: "invalid-id" } }) +} catch (error) { + console.error("Failed to get session:", (error as Error).message) +} +``` + +--- + +## 结构化输出 + +你可以通过指定带有 JSON Schema 的 `format` 来请求模型返回结构化的 JSON 输出。模型会使用 `StructuredOutput` 工具返回符合你 Schema 的经过验证的 JSON。 + +### 基本用法 + +```typescript +const result = await client.session.prompt({ + path: { id: sessionId }, + body: { + parts: [{ type: "text", text: "Research Anthropic and provide company info" }], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + company: { type: "string", description: "Company name" }, + founded: { type: "number", description: "Year founded" }, + products: { + type: "array", + items: { type: "string" }, + description: "Main products", + }, + }, + required: ["company", "founded"], + }, + }, + }, +}) + +// Access the structured output +console.log(result.data.info.structured_output) +// { company: "Anthropic", founded: 2021, products: ["Claude", "Claude API"] } +``` + +### 输出格式类型 + +| 类型 | 描述 | +| ------------- | --------------------------------------- | +| `text` | 默认值。标准文本响应(无结构化输出) | +| `json_schema` | 返回符合所提供 Schema 的经过验证的 JSON | + +### JSON Schema 格式 + +使用 `type: 'json_schema'` 时,需提供以下字段: + +| 字段 | 类型 | 描述 | +| ------------ | --------------- | ------------------------------------- | +| `type` | `'json_schema'` | 必填。指定 JSON Schema 模式 | +| `schema` | `object` | 必填。定义输出结构的 JSON Schema 对象 | +| `retryCount` | `number` | 可选。验证重试次数(默认值:2) | + +### 错误处理 + +如果模型在所有重试后仍无法生成有效的结构化输出,响应中会包含 `StructuredOutputError`: + +```typescript +if (result.data.info.error?.name === "StructuredOutputError") { + console.error("Failed to produce structured output:", result.data.info.error.message) + console.error("Attempts:", result.data.info.error.retries) +} +``` + +### 最佳实践 + +1. **在 Schema 属性中提供清晰的描述**,帮助模型理解需要提取的数据 +2. **使用 `required`** 指定哪些字段必须存在 +3. **保持 Schema 简洁** — 复杂的嵌套 Schema 可能会让模型更难正确填充 +4. **设置合适的 `retryCount`** — 对于复杂 Schema 可增加重试次数,对于简单 Schema 可减少 + +--- + +## API + +SDK 通过类型安全的客户端暴露所有服务器 API。 + +--- + +### Global + +| 方法 | 描述 | 响应 | +| ----------------- | ------------------------ | ------------------------------------ | +| `global.health()` | 检查服务器健康状态和版本 | `{ healthy: true, version: string }` | + +--- + +#### 示例 + +```javascript +const health = await client.global.health() +console.log(health.data.version) +``` + +--- + +### App + +| 方法 | 描述 | 响应 | +| -------------- | ------------------ | ------------------------------------------- | +| `app.log()` | 写入一条日志 | `boolean` | +| `app.agents()` | 列出所有可用的代理 | Agent[] | + +--- + +#### 示例 + +```javascript +// Write a log entry +await client.app.log({ + body: { + service: "my-app", + level: "info", + message: "Operation completed", + }, +}) + +// List available agents +const agents = await client.app.agents() +``` + +--- + +### Project + +| 方法 | 描述 | 响应 | +| ------------------- | ------------ | --------------------------------------------- | +| `project.list()` | 列出所有项目 | Project[] | +| `project.current()` | 获取当前项目 | Project | + +--- + +#### 示例 + +```javascript +// List all projects +const projects = await client.project.list() + +// Get current project +const currentProject = await client.project.current() +``` + +--- + +### Path + +| 方法 | 描述 | 响应 | +| ------------ | ------------ | ---------------------------------------- | +| `path.get()` | 获取当前路径 | Path | + +--- + +#### 示例 + +```javascript +// Get current path information +const pathInfo = await client.path.get() +``` + +--- + +### Config + +| 方法 | 描述 | 响应 | +| -------------------- | -------------------- | ----------------------------------------------------------------------------------------------------- | +| `config.get()` | 获取配置信息 | Config | +| `config.providers()` | 列出提供商和默认模型 | `{ providers: `Provider[]`, default: { [key: string]: string } }` | + +--- + +#### 示例 + +```javascript +const config = await client.config.get() + +const { providers, default: defaults } = await client.config.providers() +``` + +--- + +### Sessions + +| 方法 | 描述 | 备注 | +| ---------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session.list()` | 列出会话 | 返回 Session[] | +| `session.get({ path })` | 获取会话 | 返回 Session | +| `session.children({ path })` | 列出子会话 | 返回 Session[] | +| `session.create({ body })` | 创建会话 | 返回 Session | +| `session.delete({ path })` | 删除会话 | 返回 `boolean` | +| `session.update({ path, body })` | 更新会话属性 | 返回 Session | +| `session.init({ path, body })` | 分析应用并创建 `AGENTS.md` | 返回 `boolean` | +| `session.abort({ path })` | 中止正在运行的会话 | 返回 `boolean` | +| `session.share({ path })` | 分享会话 | 返回 Session | +| `session.unshare({ path })` | 取消分享会话 | 返回 Session | +| `session.summarize({ path, body })` | 总结会话 | 返回 `boolean` | +| `session.messages({ path })` | 列出会话中的消息 | 返回 `{ info: `Message`, parts: `Part[]`}[]` | +| `session.message({ path })` | 获取消息详情 | 返回 `{ info: `Message`, parts: `Part[]`}` | +| `session.prompt({ path, body })` | 发送提示词消息 | `body.noReply: true` 返回 UserMessage(仅注入上下文)。默认返回带有 AI 响应的 AssistantMessage。支持通过 `body.outputFormat` 使用[结构化输出](#结构化输出) | +| `session.command({ path, body })` | 向会话发送命令 | 返回 `{ info: `AssistantMessage`, parts: `Part[]`}` | +| `session.shell({ path, body })` | 执行 shell 命令 | 返回 AssistantMessage | +| `session.revert({ path, body })` | 撤回消息 | 返回 Session | +| `session.unrevert({ path })` | 恢复已撤回的消息 | 返回 Session | +| `postSessionByIdPermissionsByPermissionId({ path, body })` | 响应权限请求 | 返回 `boolean` | + +--- + +#### 示例 + +```javascript +// Create and manage sessions +const session = await client.session.create({ + body: { title: "My session" }, +}) + +const sessions = await client.session.list() + +// Send a prompt message +const result = await client.session.prompt({ + path: { id: session.id }, + body: { + model: { providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" }, + parts: [{ type: "text", text: "Hello!" }], + }, +}) + +// Inject context without triggering AI response (useful for plugins) +await client.session.prompt({ + path: { id: session.id }, + body: { + noReply: true, + parts: [{ type: "text", text: "You are a helpful assistant." }], + }, +}) +``` + +--- + +### Files + +| 方法 | 描述 | 响应 | +| ------------------------- | -------------------- | ----------------------------------------------------------------------------------- | +| `find.text({ query })` | 搜索文件中的文本 | 包含 `path`、`lines`、`line_number`、`absolute_offset`、`submatches` 的匹配对象数组 | +| `find.files({ query })` | 按名称查找文件和目录 | `string[]`(路径) | +| `find.symbols({ query })` | 查找工作区符号 | Symbol[] | +| `file.read({ query })` | 读取文件 | `{ type: "raw" \| "patch", content: string }` | +| `file.status({ query? })` | 获取已跟踪文件的状态 | File[] | + +`find.files` 支持以下可选查询字段: + +- `type`:`"file"` 或 `"directory"` +- `directory`:覆盖搜索的项目根目录 +- `limit`:最大结果数(1–200) + +--- + +#### 示例 + +```javascript +// Search and read files +const textResults = await client.find.text({ + query: { pattern: "function.*opencode" }, +}) + +const files = await client.find.files({ + query: { query: "*.ts", type: "file" }, +}) + +const directories = await client.find.files({ + query: { query: "packages", type: "directory", limit: 20 }, +}) + +const content = await client.file.read({ + query: { path: "src/index.ts" }, +}) +``` + +--- + +### TUI + +| 方法 | 描述 | 响应 | +| ------------------------------ | ---------------- | --------- | +| `tui.appendPrompt({ body })` | 向提示词追加文本 | `boolean` | +| `tui.openHelp()` | 打开帮助对话框 | `boolean` | +| `tui.openSessions()` | 打开会话选择器 | `boolean` | +| `tui.openThemes()` | 打开主题选择器 | `boolean` | +| `tui.openModels()` | 打开模型选择器 | `boolean` | +| `tui.submitPrompt()` | 提交当前提示词 | `boolean` | +| `tui.clearPrompt()` | 清除提示词 | `boolean` | +| `tui.executeCommand({ body })` | 执行命令 | `boolean` | +| `tui.showToast({ body })` | 显示 Toast 通知 | `boolean` | + +--- + +#### 示例 + +```javascript +// Control TUI interface +await client.tui.appendPrompt({ + body: { text: "Add this to prompt" }, +}) + +await client.tui.showToast({ + body: { message: "Task completed", variant: "success" }, +}) +``` + +--- + +### Auth + +| 方法 | 描述 | 响应 | +| ------------------- | ------------ | --------- | +| `auth.set({ ... })` | 设置认证凭据 | `boolean` | + +--- + +#### 示例 + +```javascript +await client.auth.set({ + path: { id: "anthropic" }, + body: { type: "api", key: "your-api-key" }, +}) +``` + +--- + +### Events + +| 方法 | 描述 | 响应 | +| ------------------- | ------------------ | ------------------ | +| `event.subscribe()` | 服务器发送的事件流 | 服务器发送的事件流 | + +--- + +#### 示例 + +```javascript +// Listen to real-time events +const events = await client.event.subscribe() +for await (const event of events.stream) { + console.log("Event:", event.type, event.properties) +} +``` diff --git a/packages/web/src/content/docs/zh-cn/server.mdx b/packages/web/src/content/docs/zh-cn/server.mdx new file mode 100644 index 0000000000000000000000000000000000000000..d28342ecc69a5561f3fa32aa50f227cdcb607f2a --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/server.mdx @@ -0,0 +1,284 @@ +--- +title: 服务器 +description: 通过 HTTP 与 opencode 服务器交互。 +--- + +import config from "../../../../config.mjs" +export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` + +`opencode serve` 命令运行一个无界面的 HTTP 服务器,暴露一个 OpenAPI 端点供 opencode 客户端使用。 + +--- + +### 用法 + +```bash +opencode serve [--port ] [--hostname ] [--cors ] +``` + +#### 选项 + +| 标志 | 描述 | 默认值 | +| --------------- | --------------------- | ---------------- | +| `--port` | 监听端口 | `4096` | +| `--hostname` | 监听的主机名 | `127.0.0.1` | +| `--mdns` | 启用 mDNS 发现 | `false` | +| `--mdns-domain` | mDNS 服务的自定义域名 | `opencode.local` | +| `--cors` | 额外允许的浏览器来源 | `[]` | + +`--cors` 可以多次传递: + +```bash +opencode serve --cors http://localhost:5173 --cors https://app.example.com +``` + +--- + +### 认证 + +设置 `OPENCODE_SERVER_PASSWORD` 以使用 HTTP 基本认证保护服务器。用户名默认为 `opencode`,也可以设置 `OPENCODE_SERVER_USERNAME` 来覆盖它。这适用于 `opencode serve` 和 `opencode web`。 + +```bash +OPENCODE_SERVER_PASSWORD=your-password opencode serve +``` + +--- + +### 工作原理 + +当你运行 `opencode` 时,它会启动一个 TUI 和一个服务器。TUI 是与服务器通信的客户端。服务器暴露一个 OpenAPI 3.1 规范端点。该端点也用于生成 [SDK](/docs/sdk)。 + +:::tip +使用 opencode 服务器以编程方式与 opencode 交互。 +::: + +这种架构让 opencode 支持多个客户端,并允许你以编程方式与 opencode 交互。 + +你可以运行 `opencode serve` 来启动一个独立的服务器。如果你已经在运行 opencode TUI,`opencode serve` 会启动一个新的服务器。 + +--- + +#### 连接到现有服务器 + +当你启动 TUI 时,它会随机分配端口和主机名。你也可以传入 `--hostname` 和 `--port` [标志](/docs/cli),然后用它来连接对应的服务器。 + +[`/tui`](#tui) 端点可用于通过服务器驱动 TUI。例如,你可以预填充或运行一个提示词。此方式被 OpenCode [IDE](/docs/ide) 插件所使用。 + +--- + +## 规范 + +服务器发布了一个 OpenAPI 3.1 规范,可在以下地址查看: + +``` +http://:/doc +``` + +例如,`http://localhost:4096/doc`。使用该规范可以生成客户端或检查请求和响应类型,也可以在 Swagger 浏览器中查看。 + +--- + +## API + +opencode 服务器暴露以下 API。 + +--- + +### 全局 + +| 方法 | 路径 | 描述 | 响应 | +| ----- | ---------------- | ------------------------ | ------------------------------------ | +| `GET` | `/global/health` | 获取服务器健康状态和版本 | `{ healthy: true, version: string }` | +| `GET` | `/global/event` | 获取全局事件(SSE 流) | 事件流 | + +--- + +### 项目 + +| 方法 | 路径 | 描述 | 响应 | +| ----- | ------------------ | ------------ | --------------------------------------------- | +| `GET` | `/project` | 列出所有项目 | Project[] | +| `GET` | `/project/current` | 获取当前项目 | Project | + +--- + +### 路径和 VCS + +| 方法 | 路径 | 描述 | 响应 | +| ----- | ------- | ----------------------- | ------------------------------------------- | +| `GET` | `/path` | 获取当前路径 | Path | +| `GET` | `/vcs` | 获取当前项目的 VCS 信息 | VcsInfo | + +--- + +### 实例 + +| 方法 | 路径 | 描述 | 响应 | +| ------ | ------------------- | ------------ | --------- | +| `POST` | `/instance/dispose` | 销毁当前实例 | `boolean` | + +--- + +### 配置 + +| 方法 | 路径 | 描述 | 响应 | +| ------- | ------------------- | -------------------- | ---------------------------------------------------------------------------------------- | +| `GET` | `/config` | 获取配置信息 | Config | +| `PATCH` | `/config` | 更新配置 | Config | +| `GET` | `/config/providers` | 列出提供商和默认模型 | `{ providers: `Provider[]`, default: { [key: string]: string } }` | + +--- + +### 提供商 + +| 方法 | 路径 | 描述 | 响应 | +| ------ | -------------------------------- | ----------------------- | ----------------------------------------------------------------------------------- | +| `GET` | `/provider` | 列出所有提供商 | `{ all: `Provider[]`, default: {...}, connected: string[] }` | +| `GET` | `/provider/auth` | 获取提供商认证方式 | `{ [providerID: string]: `ProviderAuthMethod[]` }` | +| `POST` | `/provider/{id}/oauth/authorize` | 使用 OAuth 授权提供商 | ProviderAuthAuthorization | +| `POST` | `/provider/{id}/oauth/callback` | 处理提供商的 OAuth 回调 | `boolean` | + +--- + +### 会话 + +| 方法 | 路径 | 描述 | 说明 | +| -------- | ---------------------------------------- | -------------------------- | --------------------------------------------------------------------------------- | +| `GET` | `/session` | 列出所有会话 | 返回 Session[] | +| `POST` | `/session` | 创建新会话 | 请求体:`{ parentID?, title? }`,返回 Session | +| `GET` | `/session/status` | 获取所有会话的状态 | 返回 `{ [sessionID: string]: `SessionStatus` }` | +| `GET` | `/session/:id` | 获取会话详情 | 返回 Session | +| `DELETE` | `/session/:id` | 删除会话及其所有数据 | 返回 `boolean` | +| `PATCH` | `/session/:id` | 更新会话属性 | 请求体:`{ title? }`,返回 Session | +| `GET` | `/session/:id/children` | 获取会话的子会话 | 返回 Session[] | +| `GET` | `/session/:id/todo` | 获取会话的待办事项列表 | 返回 Todo[] | +| `POST` | `/session/:id/init` | 分析应用并创建 `AGENTS.md` | 请求体:`{ messageID, providerID, modelID }`,返回 `boolean` | +| `POST` | `/session/:id/fork` | 在某条消息处分叉现有会话 | 请求体:`{ messageID? }`,返回 Session | +| `POST` | `/session/:id/abort` | 中止正在运行的会话 | 返回 `boolean` | +| `POST` | `/session/:id/share` | 分享会话 | 返回 Session | +| `DELETE` | `/session/:id/share` | 取消分享会话 | 返回 Session | +| `GET` | `/session/:id/diff` | 获取本次会话的差异 | 查询参数:`messageID?`,返回 FileDiff[] | +| `POST` | `/session/:id/summarize` | 总结会话 | 请求体:`{ providerID, modelID }`,返回 `boolean` | +| `POST` | `/session/:id/revert` | 回退消息 | 请求体:`{ messageID, partID? }`,返回 `boolean` | +| `POST` | `/session/:id/unrevert` | 恢复所有已回退的消息 | 返回 `boolean` | +| `POST` | `/session/:id/permissions/:permissionID` | 响应权限请求 | 请求体:`{ response, remember? }`,返回 `boolean` | + +--- + +### 消息 + +| 方法 | 路径 | 描述 | 说明 | +| ------ | --------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/session/:id/message` | 列出会话中的消息 | 查询参数:`limit?`,返回 `{ info: `Message`, parts: `Part[]`}[]` | +| `POST` | `/session/:id/message` | 发送消息并等待响应 | 请求体:`{ messageID?, model?, agent?, noReply?, system?, tools?, parts }`,返回 `{ info: `Message`, parts: `Part[]`}` | +| `GET` | `/session/:id/message/:messageID` | 获取消息详情 | 返回 `{ info: `Message`, parts: `Part[]`}` | +| `POST` | `/session/:id/prompt_async` | 异步发送消息(不等待响应) | 请求体:与 `/session/:id/message` 相同,返回 `204 No Content` | +| `POST` | `/session/:id/command` | 执行斜杠命令 | 请求体:`{ messageID?, agent?, model?, command, arguments }`,返回 `{ info: `Message`, parts: `Part[]`}` | +| `POST` | `/session/:id/shell` | 运行 shell 命令 | 请求体:`{ agent, model?, command }`,返回 `{ info: `Message`, parts: `Part[]`}` | + +--- + +### 命令 + +| 方法 | 路径 | 描述 | 响应 | +| ----- | ---------- | ------------ | --------------------------------------------- | +| `GET` | `/command` | 列出所有命令 | Command[] | + +--- + +### 文件 + +| 方法 | 路径 | 描述 | 响应 | +| ----- | ------------------------ | -------------------- | ----------------------------------------------------------------------------------- | +| `GET` | `/find?pattern=` | 在文件中搜索文本 | 包含 `path`、`lines`、`line_number`、`absolute_offset`、`submatches` 的匹配对象数组 | +| `GET` | `/find/file?query=` | 按名称查找文件和目录 | `string[]`(路径) | +| `GET` | `/find/symbol?query=` | 查找工作区符号 | Symbol[] | +| `GET` | `/file?path=` | 列出文件和目录 | FileNode[] | +| `GET` | `/file/content?path=

` | 读取文件 | FileContent | +| `GET` | `/file/status` | 获取已跟踪文件的状态 | File[] | + +#### `/find/file` 查询参数 + +- `query`(必需)— 搜索字符串(模糊匹配) +- `type`(可选)— 将结果限制为 `"file"` 或 `"directory"` +- `directory`(可选)— 覆盖搜索的项目根目录 +- `limit`(可选)— 最大结果数(1–200) +- `dirs`(可选)— 旧版标志(`"false"` 仅返回文件) + +--- + +### 工具(实验性) + +| 方法 | 路径 | 描述 | 响应 | +| ----- | ------------------------------------------- | ---------------------------------- | -------------------------------------------- | +| `GET` | `/experimental/tool/ids` | 列出所有工具 ID | ToolIDs | +| `GET` | `/experimental/tool?provider=

&model=` | 列出指定模型的工具及其 JSON Schema | ToolList | + +--- + +### LSP、格式化器和 MCP + +| 方法 | 路径 | 描述 | 响应 | +| ------ | ------------ | ------------------- | -------------------------------------------------------- | +| `GET` | `/lsp` | 获取 LSP 服务器状态 | LSPStatus[] | +| `GET` | `/formatter` | 获取格式化器状态 | FormatterStatus[] | +| `GET` | `/mcp` | 获取 MCP 服务器状态 | `{ [name: string]: `MCPStatus` }` | +| `POST` | `/mcp` | 动态添加 MCP 服务器 | 请求体:`{ name, config }`,返回 MCP 状态对象 | + +--- + +### 代理 + +| 方法 | 路径 | 描述 | 响应 | +| ----- | -------- | ------------------ | ------------------------------------------- | +| `GET` | `/agent` | 列出所有可用的代理 | Agent[] | + +--- + +### 日志 + +| 方法 | 路径 | 描述 | 响应 | +| ------ | ------ | ----------------------------------------------------------- | --------- | +| `POST` | `/log` | 写入日志条目。请求体:`{ service, level, message, extra? }` | `boolean` | + +--- + +### TUI + +| 方法 | 路径 | 描述 | 响应 | +| ------ | ----------------------- | ---------------------------------------------- | ------------ | +| `POST` | `/tui/append-prompt` | 向提示词追加文本 | `boolean` | +| `POST` | `/tui/open-help` | 打开帮助对话框 | `boolean` | +| `POST` | `/tui/open-sessions` | 打开会话选择器 | `boolean` | +| `POST` | `/tui/open-themes` | 打开主题选择器 | `boolean` | +| `POST` | `/tui/open-models` | 打开模型选择器 | `boolean` | +| `POST` | `/tui/submit-prompt` | 提交当前提示词 | `boolean` | +| `POST` | `/tui/clear-prompt` | 清除提示词 | `boolean` | +| `POST` | `/tui/execute-command` | 执行命令(`{ command }`) | `boolean` | +| `POST` | `/tui/show-toast` | 显示提示消息(`{ title?, message, variant }`) | `boolean` | +| `GET` | `/tui/control/next` | 等待下一个控制请求 | 控制请求对象 | +| `POST` | `/tui/control/response` | 响应控制请求(`{ body }`) | `boolean` | + +--- + +### 认证 + +| 方法 | 路径 | 描述 | 响应 | +| ----- | ----------- | -------------------------------------------- | --------- | +| `PUT` | `/auth/:id` | 设置认证凭据。请求体必须匹配提供商的数据结构 | `boolean` | + +--- + +### 事件 + +| 方法 | 路径 | 描述 | 响应 | +| ----- | -------- | ----------------------------------------------------------------- | ---------------- | +| `GET` | `/event` | 服务器发送事件流。第一个事件是 `server.connected`,之后是总线事件 | 服务器发送事件流 | + +--- + +### 文档 + +| 方法 | 路径 | 描述 | 响应 | +| ----- | ------ | ---------------- | ----------------------------- | +| `GET` | `/doc` | OpenAPI 3.1 规范 | 包含 OpenAPI 规范的 HTML 页面 | diff --git a/packages/web/src/content/docs/zh-cn/share.mdx b/packages/web/src/content/docs/zh-cn/share.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a2b34688e4dc5b0f54f5e987538e868a55f09110 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/share.mdx @@ -0,0 +1,127 @@ +--- +title: 分享 +description: 分享您的 OpenCode 对话。 +--- + +OpenCode 的分享功能允许您创建指向 OpenCode 对话的公开链接,方便与团队成员协作或向他人寻求帮助。 + +:::note +共享的对话对任何拥有链接的人都是公开可访问的。 +::: + +--- + +## 工作原理 + +当您分享一段对话时,OpenCode 会: + +1. 为您的会话创建一个唯一的公开 URL +2. 将您的对话历史同步到我们的服务器 +3. 通过可分享的链接使对话可访问 — `opncd.ai/s/` + +--- + +## 分享模式 + +OpenCode 支持三种分享模式,用于控制对话的共享方式: + +--- + +### 手动模式(默认) + +默认情况下,OpenCode 使用手动分享模式。会话不会自动共享,但您可以使用 `/share` 命令手动分享: + +``` +/share +``` + +这将生成一个唯一的 URL 并复制到您的剪贴板。 + +要在[配置文件](/docs/config)中显式设置手动模式: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "manual" +} +``` + +--- + +### 自动分享 + +您可以在[配置文件](/docs/config)中将 `share` 选项设置为 `"auto"`,为所有新对话启用自动分享: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "auto" +} +``` + +启用自动分享后,每个新对话都会自动共享并生成链接。 + +--- + +### 禁用 + +您可以在[配置文件](/docs/config)中将 `share` 选项设置为 `"disabled"`,完全禁用分享功能: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled" +} +``` + +要在团队中对特定项目强制执行此设置,请将其添加到项目的 `opencode.json` 文件中并提交到 Git。 + +--- + +## 取消分享 + +要停止分享对话并将其从公开访问中移除: + +``` +/unshare +``` + +这将移除分享链接并删除与该对话相关的数据。 + +--- + +## 隐私 + +分享对话时需要注意以下几点。 + +--- + +### 数据留存 + +共享的对话在您明确取消分享之前将一直保持可访问状态。这包括: + +- 完整的对话历史 +- 所有消息和回复 +- 会话元数据 + +--- + +### 建议 + +- 仅分享不包含敏感信息的对话。 +- 分享前请检查对话内容。 +- 协作完成后请取消分享。 +- 避免分享包含专有代码或机密数据的对话。 +- 对于敏感项目,请完全禁用分享功能。 + +--- + +## 企业版 + +对于企业部署,分享功能可以: + +- 出于安全合规考虑**完全禁用** +- **限制**为仅通过 SSO 身份验证的用户可用 +- **自托管**在您自己的基础设施上 + +[了解更多](/docs/enterprise)关于在您的组织中使用 OpenCode 的信息。 diff --git a/packages/web/src/content/docs/zh-cn/skills.mdx b/packages/web/src/content/docs/zh-cn/skills.mdx new file mode 100644 index 0000000000000000000000000000000000000000..1c4f2fe69df30f87d8445f938a738ec09c9bcd5d --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/skills.mdx @@ -0,0 +1,222 @@ +--- +title: "代理技能" +description: "通过 SKILL.md 定义可复用的行为" +--- + +代理技能让 OpenCode 能够从你的仓库或主目录中发现可复用的指令。 +技能通过原生的 `skill` 工具按需加载——代理可以查看可用技能,并在需要时加载完整内容。 + +--- + +## 放置文件 + +为每个技能名称创建一个文件夹,并在其中放入 `SKILL.md`。 +OpenCode 会搜索以下位置: + +- 项目配置:`.opencode/skills//SKILL.md` +- 全局配置:`~/.config/opencode/skills//SKILL.md` +- 项目 Claude 兼容:`.claude/skills//SKILL.md` +- 全局 Claude 兼容:`~/.claude/skills//SKILL.md` +- 项目代理兼容:`.agents/skills//SKILL.md` +- 全局代理兼容:`~/.agents/skills//SKILL.md` + +--- + +## 了解发现机制 + +对于项目本地路径,OpenCode 会从当前工作目录向上遍历,直到到达 git 工作树根目录。 +在此过程中,它会加载 `.opencode/` 中所有匹配的 `skills/*/SKILL.md`,以及匹配的 `.claude/skills/*/SKILL.md` 或 `.agents/skills/*/SKILL.md`。 + +全局定义也会从 `~/.config/opencode/skills/*/SKILL.md`、`~/.claude/skills/*/SKILL.md` 和 `~/.agents/skills/*/SKILL.md` 中加载。 + +--- + +## 编写 frontmatter + +每个 `SKILL.md` 必须以 YAML frontmatter 开头。 +仅识别以下字段: + +- `name`(必填) +- `description`(必填) +- `license`(可选) +- `compatibility`(可选) +- `metadata`(可选,字符串到字符串的映射) + +未知的 frontmatter 字段会被忽略。 + +--- + +## 验证名称 + +`name` 必须满足: + +- 长度为 1–64 个字符 +- 仅包含小写字母和数字,可用单个连字符分隔 +- 不以 `-` 开头或结尾 +- 不包含连续的 `--` +- 与包含 `SKILL.md` 的目录名称一致 + +等效的正则表达式: + +```text +^[a-z0-9]+(-[a-z0-9]+)*$ +``` + +--- + +## 遵循长度规则 + +`description` 必须为 1-1024 个字符。 +请保持描述足够具体,以便代理能够正确选择。 + +--- + +## 使用示例 + +创建 `.opencode/skills/git-release/SKILL.md`,内容如下: + +```markdown +--- +name: git-release +description: Create consistent releases and changelogs +license: MIT +compatibility: opencode +metadata: + audience: maintainers + workflow: github +--- + +## What I do + +- Draft release notes from merged PRs +- Propose a version bump +- Provide a copy-pasteable `gh release create` command + +## When to use me + +Use this when you are preparing a tagged release. +Ask clarifying questions if the target versioning scheme is unclear. +``` + +--- + +## 识别工具描述 + +OpenCode 会在 `skill` 工具描述中列出可用技能。 +每个条目包含技能名称和描述: + +```xml + + + git-release + Create consistent releases and changelogs + + +``` + +代理通过调用工具来加载技能: + +``` +skill({ name: "git-release" }) +``` + +--- + +## 配置权限 + +在 `opencode.json` 中使用基于模式的权限来控制代理可以访问哪些技能: + +```json +{ + "permission": { + "skill": { + "*": "allow", + "pr-review": "allow", + "internal-*": "deny", + "experimental-*": "ask" + } + } +} +``` + +| 权限 | 行为 | +| ------- | ------------------------ | +| `allow` | 技能立即加载 | +| `deny` | 对代理隐藏技能,拒绝访问 | +| `ask` | 加载前提示用户确认 | + +模式支持通配符:`internal-*` 可匹配 `internal-docs`、`internal-tools` 等。 + +--- + +## 按代理覆盖权限 + +为特定代理授予与全局默认值不同的权限。 + +**自定义代理**(在代理 frontmatter 中): + +```yaml +--- +permission: + skill: + "documents-*": "allow" +--- +``` + +**内置代理**(在 `opencode.json` 中): + +```json +{ + "agent": { + "plan": { + "permission": { + "skill": { + "internal-*": "allow" + } + } + } + } +} +``` + +--- + +## 禁用技能工具 + +为不需要使用技能的代理完全禁用技能功能: + +**自定义代理**: + +```yaml +--- +tools: + skill: false +--- +``` + +**内置代理**: + +```json +{ + "agent": { + "plan": { + "tools": { + "skill": false + } + } + } +} +``` + +禁用后,`` 部分将被完全省略。 + +--- + +## 排查加载问题 + +如果某个技能没有显示: + +1. 确认 `SKILL.md` 文件名全部为大写字母 +2. 检查 frontmatter 是否包含 `name` 和 `description` +3. 确保技能名称在所有位置中唯一 +4. 检查权限设置——设为 `deny` 的技能会对代理隐藏 diff --git a/packages/web/src/content/docs/zh-cn/themes.mdx b/packages/web/src/content/docs/zh-cn/themes.mdx new file mode 100644 index 0000000000000000000000000000000000000000..79386fbe9961b3360a2175cb09c0673edfaf3b07 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/themes.mdx @@ -0,0 +1,369 @@ +--- +title: 主题 +description: 选择内置主题或定义您自己的主题。 +--- + +通过 OpenCode,您可以从多个内置主题中进行选择,使用能自动适配终端主题的主题,或者定义您自己的自定义主题。 + +默认情况下,OpenCode 使用我们自己的 `opencode` 主题。 + +--- + +## 终端要求 + +为了使主题能够正确显示完整的调色板,您的终端必须支持**真彩色**(24 位色)。大多数现代终端默认支持此功能,但您可能需要手动启用: + +- **检查支持情况**:运行 `echo $COLORTERM` — 输出应为 `truecolor` 或 `24bit` +- **启用真彩色**:在您的 shell 配置文件中设置环境变量 `COLORTERM=truecolor` +- **终端兼容性**:确保您的终端模拟器支持 24 位色(大多数现代终端如 iTerm2、Alacritty、Kitty、Windows Terminal 以及较新版本的 GNOME Terminal 均已支持) + +如果没有真彩色支持,主题可能会出现色彩精度下降的情况,或者回退到最接近的 256 色近似值。 + +--- + +## 内置主题 + +OpenCode 自带多个内置主题。 + +| 名称 | 描述 | +| ---------------------- | ------------------------------------------------------------------- | +| `system` | 自动适配终端的背景颜色 | +| `tokyonight` | 基于 [Tokyonight](https://github.com/folke/tokyonight.nvim) 主题 | +| `everforest` | 基于 [Everforest](https://github.com/sainnhe/everforest) 主题 | +| `ayu` | 基于 [Ayu](https://github.com/ayu-theme) 暗色主题 | +| `catppuccin` | 基于 [Catppuccin](https://github.com/catppuccin) 主题 | +| `catppuccin-macchiato` | 基于 [Catppuccin](https://github.com/catppuccin) 主题 | +| `gruvbox` | 基于 [Gruvbox](https://github.com/morhetz/gruvbox) 主题 | +| `kanagawa` | 基于 [Kanagawa](https://github.com/rebelot/kanagawa.nvim) 主题 | +| `nord` | 基于 [Nord](https://github.com/nordtheme/nord) 主题 | +| `matrix` | 黑客风格的黑底绿字主题 | +| `one-dark` | 基于 [Atom One](https://github.com/Th3Whit3Wolf/one-nvim) Dark 主题 | + +我们还在不断添加更多主题。 + +--- + +## 系统主题 + +`system` 主题旨在自动适配您终端的配色方案。与使用固定颜色的传统主题不同,_system_ 主题具有以下特点: + +- **生成灰度色阶**:根据终端的背景颜色创建自定义灰度色阶,确保最佳对比度。 +- **使用 ANSI 颜色**:利用标准 ANSI 颜色(0-15)进行语法高亮和 UI 元素渲染,遵循终端的调色板设置。 +- **保留终端默认值**:将文本和背景颜色设为 `none`,以保持终端的原生外观。 + +系统主题适合以下用户: + +- 希望 OpenCode 与终端的外观保持一致 +- 使用了自定义终端配色方案 +- 偏好所有终端应用程序拥有统一的视觉风格 + +--- + +## 使用主题 + +您可以通过 `/theme` 命令调出主题选择界面来选择主题,也可以在 `tui.json` 文件中直接指定。 + +```json title="tui.json" {3} +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "tokyonight" +} +``` + +--- + +## 自定义主题 + +OpenCode 支持灵活的基于 JSON 的主题系统,让用户可以轻松创建和自定义主题。 + +--- + +### 层级优先级 + +主题按以下顺序从多个目录加载,后面的目录会覆盖前面的目录: + +1. **内置主题** — 嵌入在二进制文件中 +2. **用户配置目录** — 定义在 `~/.config/opencode/themes/*.json` 或 `$XDG_CONFIG_HOME/opencode/themes/*.json` +3. **项目根目录** — 定义在 `/.opencode/themes/*.json` +4. **当前工作目录** — 定义在 `./.opencode/themes/*.json` + +如果多个目录包含同名主题,将使用优先级较高的目录中的主题。 + +--- + +### 创建主题 + +要创建自定义主题,请在上述任一主题目录中创建一个 JSON 文件。 + +创建用户级主题: + +```bash no-frame +mkdir -p ~/.config/opencode/themes +vim ~/.config/opencode/themes/my-theme.json +``` + +创建项目级主题: + +```bash no-frame +mkdir -p .opencode/themes +vim .opencode/themes/my-theme.json +``` + +--- + +### JSON 格式 + +主题使用灵活的 JSON 格式,支持以下特性: + +- **十六进制颜色**:`"#ffffff"` +- **ANSI 颜色**:`3`(0-255) +- **颜色引用**:`"primary"` 或自定义定义的颜色名 +- **深色/浅色变体**:`{"dark": "#000", "light": "#fff"}` +- **无颜色**:`"none"` — 使用终端的默认颜色或透明背景 + +--- + +### 颜色定义 + +`defs` 部分是可选的,它允许您定义可在主题中重复引用的可复用颜色。 + +--- + +### 终端默认值 + +特殊值 `"none"` 可用于任何颜色属性,以继承终端的默认颜色。这在创建需要与终端配色方案无缝融合的主题时特别有用: + +- `"text": "none"` — 使用终端的默认前景色 +- `"background": "none"` — 使用终端的默认背景色 + +--- + +### 示例 + +以下是一个自定义主题的完整示例: + +```json title="my-theme.json" +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD" + }, + "theme": { + "primary": { + "dark": "nord8", + "light": "nord10" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord4", + "light": "nord0" + }, + "textMuted": { + "dark": "nord3", + "light": "nord1" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord1", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "nord3", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "nord3", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "nord2", + "light": "nord4" + }, + "diffAddedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "nord3", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "nord3", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "nord3", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" + } + } +} +``` diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx new file mode 100644 index 0000000000000000000000000000000000000000..1a58eece5d37fc5bafaac16ab0600d81b00e275e --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -0,0 +1,341 @@ +--- +title: 工具 +description: 管理 LLM 可以使用的工具。 +--- + +工具允许 LLM 在您的代码库中执行操作。OpenCode 自带一组内置工具,您也可以通过[自定义工具](/docs/custom-tools)或 [MCP 服务器](/docs/mcp-servers)来扩展它。 + +默认情况下,所有工具都是**启用**的,且无需权限即可运行。您可以通过[权限](/docs/permissions)来控制工具的行为。 + +--- + +## 配置 + +使用 `permission` 字段来控制工具行为。您可以对每个工具设置允许、拒绝或需要审批。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny", + "bash": "ask", + "webfetch": "allow" + } +} +``` + +您还可以使用通配符同时控制多个工具。例如,要求某个 MCP 服务器的所有工具都需要审批: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "mymcp_*": "ask" + } +} +``` + +[了解更多](/docs/permissions)关于配置权限的内容。 + +--- + +## 内置工具 + +以下是 OpenCode 中所有可用的内置工具。 + +--- + +### bash + +在项目环境中执行 shell 命令。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": "allow" + } +} +``` + +该工具允许 LLM 运行终端命令,例如 `npm install`、`git status` 或其他任何 shell 命令。 + +--- + +### edit + +通过精确的字符串替换来修改现有文件。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +该工具通过替换精确匹配的文本来对文件进行编辑。这是 LLM 修改代码的主要方式。 + +--- + +### write + +创建新文件或覆盖现有文件。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +使用此工具允许 LLM 创建新文件。如果文件已存在,则会覆盖现有文件。 + +:::note +`write` 工具由 `edit` 权限控制,该权限涵盖所有文件修改操作(`edit`、`write`、`patch`)。 +::: + +--- + +### read + +读取代码库中的文件内容。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "read": "allow" + } +} +``` + +该工具读取文件并返回其内容。它支持对大文件读取指定行范围。 + +--- + +### grep + +使用正则表达式搜索文件内容。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "grep": "allow" + } +} +``` + +在代码库中快速搜索内容。支持完整的正则表达式语法和文件模式过滤。 + +--- + +### glob + +通过模式匹配查找文件。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "glob": "allow" + } +} +``` + +使用 `**/*.js` 或 `src/**/*.ts` 等 glob 模式搜索文件。返回按修改时间排序的匹配文件路径。 + +--- + +### lsp(实验性) + +与已配置的 LSP 服务器交互,获取代码智能功能,如定义跳转、引用查找、悬停信息和调用层次结构。 + +:::note +该工具仅在设置 `OPENCODE_EXPERIMENTAL_LSP_TOOL=true`(或 `OPENCODE_EXPERIMENTAL=true`)时可用。 +::: + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "lsp": "allow" + } +} +``` + +支持的操作包括 `goToDefinition`、`findReferences`、`hover`、`documentSymbol`、`workspaceSymbol`、`goToImplementation`、`prepareCallHierarchy`、`incomingCalls` 和 `outgoingCalls`。 + +要配置项目可用的 LSP 服务器,请参阅 [LSP 服务器](/docs/lsp)。 + +--- + +### patch + +对文件应用补丁。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +该工具将补丁文件应用到您的代码库中。适用于应用来自各种来源的 diff 和补丁。 + +:::note +`patch` 工具由 `edit` 权限控制,该权限涵盖所有文件修改操作(`edit`、`write`、`patch`)。 +::: + +--- + +### skill + +加载一个[技能](/docs/skills)(即 `SKILL.md` 文件)并在对话中返回其内容。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "skill": "allow" + } +} +``` + +--- + +### todowrite + +在编码会话中管理待办事项列表。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "todowrite": "allow" + } +} +``` + +创建和更新任务列表以跟踪复杂操作的进度。LLM 使用此工具来组织多步骤任务。 + +:::note +该工具默认对子代理禁用,但您可以手动启用。[了解更多](/docs/agents/#permissions) +::: + +--- + +### webfetch + +获取网页内容。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "webfetch": "allow" + } +} +``` + +允许 LLM 获取并读取网页内容。适用于查阅文档或研究在线资源。 + +--- + +### websearch + +在网络上搜索信息。 + +:::note +该工具仅在使用 OpenCode 提供商时,或当 `OPENCODE_ENABLE_EXA` 环境变量设置为任意真值(例如 `true` 或 `1`)时可用。 + +在启动 OpenCode 时启用: + +```bash +OPENCODE_ENABLE_EXA=1 opencode +``` + +::: + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "websearch": "allow" + } +} +``` + +使用 Exa AI 进行网络搜索以查找相关信息。适用于研究主题、了解时事动态或获取超出训练数据截止日期的信息。 + +无需 API 密钥——该工具无需身份验证即可直接连接到 Exa AI 的托管 MCP 服务。 + +:::tip +当您需要查找信息(发现)时使用 `websearch`,当您需要从特定 URL 获取内容(检索)时使用 `webfetch`。 +::: + +--- + +### question + +在执行过程中向用户提问。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "question": "allow" + } +} +``` + +该工具允许 LLM 在执行任务期间向用户提问。适用于以下场景: + +- 收集用户偏好或需求 +- 澄清模糊的指令 +- 获取实现方案的决策 +- 提供方向选择的选项 + +每个问题包含标题、问题正文和选项列表。用户可以从提供的选项中选择,也可以输入自定义答案。当有多个问题时,用户可以在提交所有答案之前在各问题之间切换浏览。 + +--- + +## 自定义工具 + +自定义工具允许您定义 LLM 可以调用的自定义函数。这些函数在您的配置文件中定义,可以执行任意代码。 + +[了解更多](/docs/custom-tools)关于创建自定义工具的内容。 + +--- + +## MCP 服务器 + +MCP(Model Context Protocol)服务器允许您集成外部工具和服务,包括数据库访问、API 集成和第三方服务。 + +[了解更多](/docs/mcp-servers)关于配置 MCP 服务器的内容。 + +--- + +## 内部机制 + +在内部,`grep` 和 `glob` 等工具底层使用 [ripgrep](https://github.com/BurntSushi/ripgrep)。默认情况下,ripgrep 遵循 `.gitignore` 中的模式,这意味着 `.gitignore` 中列出的文件和目录将被排除在搜索和列表结果之外。 + +--- + +### 忽略模式 + +要包含通常会被忽略的文件,请在项目根目录下创建一个 `.ignore` 文件。该文件可以显式允许某些路径。 + +```text title=".ignore" +!node_modules/ +!dist/ +!build/ +``` + +例如,这个 `.ignore` 文件允许 ripgrep 在 `node_modules/`、`dist/` 和 `build/` 目录中进行搜索,即使它们已在 `.gitignore` 中列出。 diff --git a/packages/web/src/content/docs/zh-cn/troubleshooting.mdx b/packages/web/src/content/docs/zh-cn/troubleshooting.mdx new file mode 100644 index 0000000000000000000000000000000000000000..3f22cbf895761d31008de1f29ece70d0ba899d47 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/troubleshooting.mdx @@ -0,0 +1,299 @@ +--- +title: 故障排除 +description: 常见问题及其解决方法。 +--- + +要调试 OpenCode 的问题,请先检查其存储在磁盘上的日志和本地数据。 + +--- + +## 日志 + +日志文件写入位置: + +- **macOS/Linux**: `~/.local/share/opencode/log/` +- **Windows**: 按 `WIN+R` 并粘贴 `%USERPROFILE%\.local\share\opencode\log` + +日志文件以时间戳命名(例如 `2025-01-09T123456.log`),并保留最近的 10 个日志文件。 + +你可以通过 `--log-level` 命令行选项设置日志级别以获取更详细的调试信息。例如:`opencode --log-level DEBUG`。 + +--- + +## 存储 + +OpenCode 将会话数据和其他应用数据存储在磁盘上: + +- **macOS/Linux**: `~/.local/share/opencode/` +- **Windows**: 按 `WIN+R` 并粘贴 `%USERPROFILE%\.local\share\opencode` + +该目录包含: + +- `auth.json` - 身份验证数据,如 API 密钥、OAuth Token +- `log/` - 应用日志 +- `project/` - 项目特定数据,如会话和消息数据 + - 如果项目位于 Git 仓库中,则存储在 `.//storage/` + - 如果不是 Git 仓库,则存储在 `./global/storage/` + +--- + +## 桌面应用 + +OpenCode Desktop 会在后台运行一个本地 OpenCode 服务器(即 `opencode-cli` 附属进程)。大多数问题是由插件异常、缓存损坏或错误的服务器设置引起的。 + +### 快速检查 + +- 完全退出并重新启动应用。 +- 如果应用显示错误页面,请点击**重新启动**并复制错误详情。 +- 仅限 macOS:`OpenCode` 菜单 -> **Reload Webview**(当 UI 空白或冻结时有效)。 + +--- + +### 禁用插件 + +如果桌面应用在启动时崩溃、卡住或行为异常,请先禁用插件。 + +#### 检查全局配置 + +打开你的全局配置文件,查找 `plugin` 键。 + +- **macOS/Linux**: `~/.config/opencode/opencode.jsonc`(或 `~/.config/opencode/opencode.json`) +- **macOS/Linux**(旧版安装): `~/.local/share/opencode/opencode.jsonc` +- **Windows**: 按 `WIN+R` 并粘贴 `%USERPROFILE%\.config\opencode\opencode.jsonc` + +如果你配置了插件,请通过移除该键或将其设置为空数组来临时禁用它们: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [], +} +``` + +#### 检查插件目录 + +OpenCode 还可以从磁盘加载本地插件。临时将这些插件移走(或重命名文件夹),然后重新启动桌面应用: + +- **全局插件** + - **macOS/Linux**: `~/.config/opencode/plugins/` + - **Windows**: 按 `WIN+R` 并粘贴 `%USERPROFILE%\.config\opencode\plugins` +- **项目插件**(仅当你使用了项目级配置时) + - `/.opencode/plugins/` + +如果应用恢复正常,请逐个重新启用插件,找出导致问题的那个。 + +--- + +### 清除缓存 + +如果禁用插件没有帮助(或插件安装卡住了),请清除缓存以便 OpenCode 重新构建。 + +1. 完全退出 OpenCode Desktop。 +2. 删除缓存目录: + +- **macOS**: Finder -> `Cmd+Shift+G` -> 粘贴 `~/.cache/opencode` +- **Linux**: 删除 `~/.cache/opencode`(或运行 `rm -rf ~/.cache/opencode`) +- **Windows**: 按 `WIN+R` 并粘贴 `%USERPROFILE%\.cache\opencode` + +3. 重新启动 OpenCode Desktop。 + +--- + +### 修复服务器连接问题 + +OpenCode Desktop 可以启动自己的本地服务器(默认行为),也可以连接到你配置的服务器 URL。 + +如果你看到**"Connection Failed"**对话框(或应用始终停留在启动画面),请检查自定义服务器 URL。 + +#### 清除桌面默认服务器 URL + +在主页面上,点击服务器名称(带有状态指示点)以打开服务器选择器。在**默认服务器**部分,点击**清除**。 + +#### 从配置中移除 `server.port` / `server.hostname` + +如果你的 `opencode.json(c)` 包含 `server` 部分,请临时移除该部分并重新启动桌面应用。 + +#### 检查环境变量 + +如果你在环境中设置了 `OPENCODE_PORT`,桌面应用将尝试使用该端口作为本地服务器端口。 + +- 取消设置 `OPENCODE_PORT`(或选择一个空闲端口)并重新启动。 + +--- + +### Linux: Wayland / X11 问题 + +在 Linux 上,某些 Wayland 设置可能会导致窗口空白或合成器错误。 + +- 如果你使用 Wayland 且应用出现空白或崩溃,请尝试使用 `OC_ALLOW_WAYLAND=1` 启动。 +- 如果情况变得更糟,请移除该设置并尝试在 X11 会话下启动。 + +--- + +### Windows: WebView2 运行时 + +在 Windows 上,OpenCode Desktop 需要 Microsoft Edge **WebView2 Runtime**。如果应用打开后是空白窗口或无法启动,请安装或更新 WebView2 后重试。 + +--- + +### Windows: 常见性能问题 + +如果你在 Windows 上遇到性能缓慢、文件访问问题或终端问题,请尝试使用 [WSL (Windows Subsystem for Linux)](/docs/windows-wsl)。WSL 提供了一个 Linux 环境,能更好地与 OpenCode 的功能兼容。 + +--- + +### 通知不显示 + +OpenCode Desktop 仅在以下情况下显示系统通知: + +- 在操作系统设置中已为 OpenCode 启用通知,且 +- 应用窗口未处于焦点状态。 + +--- + +### 重置桌面应用存储(最后手段) + +如果应用无法启动且你无法从 UI 内部清除设置,请重置桌面应用的保存状态。 + +1. 退出 OpenCode Desktop。 +2. 找到并删除以下文件(它们位于 OpenCode Desktop 应用数据目录中): + +- `opencode.settings.dat`(桌面默认服务器 URL) +- `opencode.global.dat` 和 `opencode.workspace.*.dat`(UI 状态,如最近的服务器/项目) + +快速找到该目录: + +- **macOS**: Finder -> `Cmd+Shift+G` -> `~/Library/Application Support`(然后搜索上述文件名) +- **Linux**: 在 `~/.local/share` 下搜索上述文件名 +- **Windows**: 按 `WIN+R` -> `%APPDATA%`(然后搜索上述文件名) + +--- + +## 获取帮助 + +如果你遇到 OpenCode 的问题: + +1. **在 GitHub 上报告问题** + + 报告 Bug 或请求功能的最佳方式是通过我们的 GitHub 仓库: + + [**github.com/anomalyco/opencode/issues**](https://github.com/anomalyco/opencode/issues) + + 在创建新 Issue 之前,请先搜索已有的 Issue,看看你的问题是否已被报告。 + +2. **加入我们的 Discord** + + 如需实时帮助和社区讨论,请加入我们的 Discord 服务器: + + [**opencode.ai/discord**](https://opencode.ai/discord) + +--- + +## 常见问题 + +以下是一些常见问题及其解决方法。 + +--- + +### OpenCode 无法启动 + +1. 检查日志中的错误消息 +2. 尝试使用 `--print-logs` 运行以在终端中查看输出 +3. 使用 `opencode upgrade` 确保你使用的是最新版本 + +--- + +### 身份验证问题 + +1. 尝试在 TUI 中使用 `/connect` 命令重新进行身份验证 +2. 检查你的 API 密钥是否有效 +3. 确保你的网络允许连接到提供商的 API + +--- + +### 模型不可用 + +1. 检查你是否已通过提供商的身份验证 +2. 验证配置中的模型名称是否正确 +3. 某些模型可能需要特定的访问权限或订阅 + +如果你遇到 `ProviderModelNotFoundError`,很可能是在某处错误地引用了模型。 +模型应按如下方式引用:`/` + +示例: + +- `openai/gpt-4.1` +- `openrouter/google/gemini-2.5-flash` +- `opencode/kimi-k2` + +要查看你有权访问哪些模型,请运行 `opencode models` + +--- + +### ProviderInitError + +如果你遇到 ProviderInitError,很可能是配置无效或已损坏。 + +要解决此问题: + +1. 首先,按照[提供商指南](/docs/providers)验证你的提供商是否已正确设置 +2. 如果问题仍然存在,请尝试清除已存储的配置: + + ```bash + rm -rf ~/.local/share/opencode + ``` + + 在 Windows 上,按 `WIN+R` 并删除:`%USERPROFILE%\.local\share\opencode` + +3. 在 TUI 中使用 `/connect` 命令重新与提供商进行身份验证。 + +--- + +### AI_APICallError 和提供商包问题 + +如果你遇到 API 调用错误,可能是由于提供商包过期导致的。OpenCode 会根据需要动态安装提供商包(OpenAI、Anthropic、Google 等)并将它们缓存到本地。 + +要解决提供商包问题: + +1. 清除提供商包缓存: + + ```bash + rm -rf ~/.cache/opencode + ``` + + 在 Windows 上,按 `WIN+R` 并删除:`%USERPROFILE%\.cache\opencode` + +2. 重新启动 OpenCode 以重新安装最新的提供商包 + +这将强制 OpenCode 下载最新版本的提供商包,通常可以解决模型参数和 API 变更带来的兼容性问题。 + +--- + +### 在 Linux 上复制/粘贴不可用 + +Linux 用户需要安装以下剪贴板工具之一,复制/粘贴功能才能正常工作: + +**对于 X11 系统:** + +```bash +apt install -y xclip +# or +apt install -y xsel +``` + +**对于 Wayland 系统:** + +```bash +apt install -y wl-clipboard +``` + +**对于无头环境:** + +```bash +apt install -y xvfb +# and run: +Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & +export DISPLAY=:99.0 +``` + +OpenCode 会检测你是否正在使用 Wayland 并优先使用 `wl-clipboard`,否则将按以下顺序尝试查找剪贴板工具:`xclip` 和 `xsel`。 diff --git a/packages/web/src/content/docs/zh-cn/tui.mdx b/packages/web/src/content/docs/zh-cn/tui.mdx new file mode 100644 index 0000000000000000000000000000000000000000..8b0a4085b0049a4ff8be413c29a426d5ec623acd --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/tui.mdx @@ -0,0 +1,426 @@ +--- +title: TUI +description: 使用 OpenCode 终端用户界面。 +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" + +OpenCode 提供了一个交互式终端界面(TUI),用于配合 LLM 处理您的项目。 + +运行 OpenCode 即可启动当前目录的 TUI。 + +```bash +opencode +``` + +或者您可以为指定的工作目录启动它。 + +```bash +opencode /path/to/project +``` + +进入 TUI 后,您可以输入消息进行提示。 + +```text +Give me a quick summary of the codebase. +``` + +--- + +## 文件引用 + +您可以使用 `@` 在消息中引用文件。这会在当前工作目录中进行模糊文件搜索。 + +:::tip +您还可以使用 `@` 来引用消息中的文件。 +::: + +```text "@packages/functions/src/api/index.ts" +How is auth handled in @packages/functions/src/api/index.ts? +``` + +文件的内容会自动添加到对话中。 + +--- + +## Bash 命令 + +以 `!` 开头的消息会作为 shell 命令执行。 + +```bash frame="none" +!ls -la +``` + +命令的输出会作为工具结果添加到对话中。 + +--- + +## 命令 + +使用 OpenCode TUI 时,您可以输入 `/` 后跟命令名称来快速执行操作。例如: + +```bash frame="none" +/help +``` + +大多数命令还支持以 `ctrl+x` 作为前导键的快捷键,其中 `ctrl+x` 是默认前导键。[了解更多](/docs/keybinds)。 + +以下是所有可用的斜杠命令: + +--- + +### connect + +将提供商添加到 OpenCode。允许您从可用的提供商中选择并添加其 API 密钥。 + +```bash frame="none" +/connect +``` + +--- + +### compact + +压缩当前会话。_别名_:`/summarize` + +```bash frame="none" +/compact +``` + +**快捷键:** `ctrl+x c` + +--- + +### details + +切换工具执行详情的显示。 + +```bash frame="none" +/details +``` + +**快捷键:** `ctrl+x d` + +--- + +### editor + +打开外部编辑器来编写消息。使用 `EDITOR` 环境变量中设置的编辑器。[了解更多](#editor-setup)。 + +```bash frame="none" +/editor +``` + +**快捷键:** `ctrl+x e` + +--- + +### exit + +退出 OpenCode。_别名_:`/quit`、`/q` + +```bash frame="none" +/exit +``` + +**快捷键:** `ctrl+x q` + +--- + +### export + +将当前对话导出为 Markdown 并在默认编辑器中打开。使用 `EDITOR` 环境变量中设置的编辑器。[了解更多](#editor-setup)。 + +```bash frame="none" +/export +``` + +**快捷键:** `ctrl+x x` + +--- + +### help + +显示帮助对话框。 + +```bash frame="none" +/help +``` + +**快捷键:** `ctrl+x h` + +--- + +### init + +创建或更新 `AGENTS.md` 文件。[了解更多](/docs/rules)。 + +```bash frame="none" +/init +``` + +**快捷键:** `ctrl+x i` + +--- + +### models + +列出可用模型。 + +```bash frame="none" +/models +``` + +**快捷键:** `ctrl+x m` + +--- + +### new + +开始新的会话。_别名_:`/clear` + +```bash frame="none" +/new +``` + +**快捷键:** `ctrl+x n` + +--- + +### redo + +重做之前撤销的消息。仅在使用 `/undo` 后可用。 + +:::tip +所有文件更改也会被恢复。 +::: + +在内部,这使用 Git 来管理文件更改。因此您的项目**需要是一个 Git 仓库**。 + +```bash frame="none" +/redo +``` + +**快捷键:** `ctrl+x r` + +--- + +### sessions + +列出会话并在会话之间切换。_别名_:`/resume`、`/continue` + +```bash frame="none" +/sessions +``` + +**快捷键:** `ctrl+x l` + +--- + +### share + +分享当前会话。[了解更多](/docs/share)。 + +```bash frame="none" +/share +``` + +**快捷键:** `ctrl+x s` + +--- + +### themes + +列出可用主题。 + +```bash frame="none" +/themes +``` + +**快捷键:** `ctrl+x t` + +--- + +### thinking + +切换对话中思考/推理块的可见性。启用后,您可以看到支持扩展思考的模型的推理过程。 + +:::note +此命令仅控制思考块是否**显示** — 它不会启用或禁用模型的推理能力。要切换实际的推理能力,请使用 `ctrl+t` 循环切换模型变体。 +::: + +```bash frame="none" +/thinking +``` + +--- + +### undo + +撤销对话中的最后一条消息。移除最近的用户消息、所有后续响应以及所有文件更改。 + +:::tip +所做的任何文件更改也会被还原。 +::: + +在内部,这使用 Git 来管理文件更改。因此您的项目**需要是一个 Git 仓库**。 + +```bash frame="none" +/undo +``` + +**快捷键:** `ctrl+x u` + +--- + +### unshare + +取消分享当前会话。[了解更多](/docs/share#un-sharing)。 + +```bash frame="none" +/unshare +``` + +--- + +## 编辑器设置 + +`/editor` 和 `/export` 命令都使用 `EDITOR` 环境变量中指定的编辑器。 + + + + ```bash + # Example for nano or vim + export EDITOR=nano + export EDITOR=vim + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + export EDITOR="code --wait" + ``` + + 要使其永久生效,请将其添加到您的 shell 配置文件中; + `~/.bashrc`、`~/.zshrc` 等。 + + + + + ```bash + set EDITOR=notepad + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + set EDITOR=code --wait + ``` + + 要使其永久生效,请使用**系统属性** > **环境变量**。 + + + + + ```powershell + $env:EDITOR = "notepad" + + # For GUI editors, VS Code, Cursor, VSCodium, Windsurf, Zed, etc. + # include --wait + $env:EDITOR = "code --wait" + ``` + + 要使其永久生效,请将其添加到您的 PowerShell 配置文件中。 + + + + +常用的编辑器选项包括: + +- `code` - Visual Studio Code +- `cursor` - Cursor +- `windsurf` - Windsurf +- `nvim` - Neovim 编辑器 +- `vim` - Vim 编辑器 +- `nano` - Nano 编辑器 +- `notepad` - Notepad(Windows 记事本) +- `subl` - Sublime Text + +:::note +某些编辑器(如 VS Code)需要以 `--wait` 标志启动。 +::: + +某些编辑器需要命令行参数才能以阻塞模式运行。`--wait` 标志使编辑器进程阻塞直到关闭。 + +--- + +## 配置 + +您可以通过 `tui.json`(或 `tui.jsonc`)自定义 TUI 行为。 + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "opencode", + "leader_timeout": 2000, + "keybinds": { + "leader": "ctrl+x", + "command_list": "ctrl+p" + }, + "scroll_speed": 3, + "scroll_acceleration": { + "enabled": false + }, + "diff_style": "auto", + "mouse": true, + "attention": { + "enabled": true, + "notifications": true, + "sound": true, + "volume": 0.4, + "sound_pack": "opencode.default", + "sounds": { + "error": "./sounds/error.mp3" + } + } +} +``` + +这与 `opencode.json` 是分开的;`opencode.json` 用于配置服务器和运行时行为。 + +`keybinds` 会与内置默认值合并,因此你只需要配置想要修改的快捷键。 + +### 选项 + +- `theme` - 设置 UI 主题。[了解更多](/docs/themes)。 +- `keybinds` - 自定义键盘快捷键。[了解更多](/docs/keybinds)。 +- `leader_timeout` - 控制按下 leader key 后 OpenCode 等待后续按键的时间。默认为 `2000`。 +- `scroll_acceleration.enabled` - 启用 macOS 风格的滚动加速,让滚动更平滑自然。启用后,快速滚动时速度会增加,慢速移动时仍保持精确。**此设置优先于 `scroll_speed`,启用时会覆盖它。** +- `scroll_speed` - 控制使用滚动命令时 TUI 的滚动速度(最小值:`0.001`,支持小数)。默认为 `3`。**注意:如果 `scroll_acceleration.enabled` 设置为 `true`,则此设置会被忽略。** +- `diff_style` - 控制 diff 的显示方式。`"auto"` 会根据终端宽度自适应,`"stacked"` 始终显示单列布局。 +- `mouse` - 在 TUI 中启用或禁用鼠标捕获(默认:`true`)。禁用后,终端原生的鼠标选择和滚动行为会保留下来。 +- `attention` - 配置 TUI 桌面通知和声音。默认禁用。 + +使用 `OPENCODE_TUI_CONFIG` 可以加载自定义的 TUI 配置文件路径。 + +### Attention + +当 OpenCode 需要你处理问题、批准权限请求、查看会话错误,或想告知会话已完成时,TUI 可以通过声音和桌面通知提醒你。设置 `attention.enabled` 后会启用这些提醒;内置事件触发时会播放声音。桌面通知只会在终端窗口未聚焦时发送,并且不会用于 subagent 事件。 + +- `enabled` - 开启 Attention 的所有通知和声音。默认为 `false`。 +- `notifications` - 启用 Attention 后,允许 TUI 通过终端发送桌面通知。默认为 `true`。 +- `sound` - 启用 Attention 后,允许播放提示音。默认为 `true`。 +- `volume` - 默认提示音音量,范围从 `0` 到 `1`。默认为 `0.4`。 +- `sound_pack` - 要使用的 sound pack ID。默认为 `opencode.default`。 +- `sounds` - 为 `default`、`question`、`permission`、`error`、`done` 或 `subagent_done` 指定自定义声音文件。路径可以是绝对路径、`file://` URL,或相对于 `tui.json` 的路径。 + +--- + +## 自定义 + +您可以使用命令面板(`ctrl+x h` 或 `/help`)自定义 TUI 视图的各个方面。这些设置在重启后仍会保留。 + +--- + +#### 用户名显示 + +切换您的用户名是否显示在聊天消息中。通过以下方式访问: + +- 命令面板:搜索 "username" 或 "hide username" +- 该设置会自动保存,并在各个 TUI 会话中保持记忆 diff --git a/packages/web/src/content/docs/zh-cn/web.mdx b/packages/web/src/content/docs/zh-cn/web.mdx new file mode 100644 index 0000000000000000000000000000000000000000..5b5a31653f28078cf29ea20cee16203d9c8b89b1 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/web.mdx @@ -0,0 +1,142 @@ +--- +title: Web +description: 在浏览器中使用 OpenCode。 +--- + +OpenCode 可以作为 Web 应用在浏览器中运行,无需终端即可获得同样强大的 AI 编码体验。 + +![OpenCode Web - New Session](../../../assets/web/web-homepage-new-session.png) + +## 快速开始 + +运行以下命令启动 Web 界面: + +```bash +opencode web +``` + +这会在 `127.0.0.1` 上启动一个本地服务器,使用随机可用端口,并自动在默认浏览器中打开 OpenCode。 + +:::caution +如果未设置 `OPENCODE_SERVER_PASSWORD`,服务器将没有安全保护。本地使用没有问题,但在网络访问时应当设置密码。 +::: + +:::tip[Windows 用户] +为获得最佳体验,建议从 [WSL](/docs/windows-wsl) 而非 PowerShell 运行 `opencode web`。这可以确保正确的文件系统访问和终端集成。 +::: + +--- + +## 配置 + +你可以通过命令行标志或[配置文件](/docs/config)来配置 Web 服务器。 + +### 端口 + +默认情况下,OpenCode 会选择一个可用端口。你也可以指定端口: + +```bash +opencode web --port 4096 +``` + +### 主机名 + +默认情况下,服务器绑定到 `127.0.0.1`(仅限本地访问)。要使 OpenCode 在网络中可访问: + +```bash +opencode web --hostname 0.0.0.0 +``` + +使用 `0.0.0.0` 时,OpenCode 会同时显示本地地址和网络地址: + +``` + Local access: http://localhost:4096 + Network access: http://192.168.1.100:4096 +``` + +### mDNS 发现 + +启用 mDNS 可以让你的服务器在本地网络中被自动发现: + +```bash +opencode web --mdns +``` + +这会自动将主机名设置为 `0.0.0.0`,并将服务器广播为 `opencode.local`。 + +你可以自定义 mDNS 域名,以便在同一网络中运行多个实例: + +```bash +opencode web --mdns --mdns-domain myproject.local +``` + +### CORS + +要为 CORS 添加额外的允许域名(适用于自定义前端): + +```bash +opencode web --cors https://example.com +``` + +### 身份验证 + +要保护服务器访问,可以通过 `OPENCODE_SERVER_PASSWORD` 环境变量设置密码: + +```bash +OPENCODE_SERVER_PASSWORD=secret opencode web +``` + +用户名默认为 `opencode`,可以通过 `OPENCODE_SERVER_USERNAME` 进行更改。 + +--- + +## 使用 Web 界面 + +启动后,Web 界面提供对 OpenCode 会话的访问。 + +### 会话 + +在主页上查看和管理你的会话。你可以查看活跃的会话,也可以创建新的会话。 + +![OpenCode Web - Active Session](../../../assets/web/web-homepage-active-session.png) + +### 服务器状态 + +点击"See Servers"可以查看已连接的服务器及其状态。 + +![OpenCode Web - See Servers](../../../assets/web/web-homepage-see-servers.png) + +--- + +## 连接终端 + +你可以将终端 TUI 连接到正在运行的 Web 服务器: + +```bash +# 启动 Web 服务器 +opencode web --port 4096 + +# 在另一个终端中连接 TUI +opencode attach http://localhost:4096 +``` + +这样你就可以同时使用 Web 界面和终端,共享相同的会话和状态。 + +--- + +## 配置文件 + +你也可以在 `opencode.json` 配置文件中设置服务器选项: + +```json +{ + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true, + "cors": ["https://example.com"] + } +} +``` + +命令行标志的优先级高于配置文件中的设置。 diff --git a/packages/web/src/content/docs/zh-cn/windows-wsl.mdx b/packages/web/src/content/docs/zh-cn/windows-wsl.mdx new file mode 100644 index 0000000000000000000000000000000000000000..853011acee234fe0ab865b5650ea9a12bc66b549 --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/windows-wsl.mdx @@ -0,0 +1,112 @@ +--- +title: Windows (WSL) +description: 通过 WSL 在 Windows 上运行 OpenCode 以获得最佳体验。 +--- + +import { Steps } from "@astrojs/starlight/components" + +虽然 OpenCode 可以直接在 Windows 上运行,但我们推荐使用 [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) 以获得最佳体验。WSL 提供了一个 Linux 环境,能够与 OpenCode 的各项功能无缝配合。 + +:::tip[为什么选择 WSL?] +WSL 提供更出色的文件系统性能、完整的终端支持,以及与 OpenCode 所依赖的开发工具的良好兼容性。 +::: + +--- + +## 安装配置 + + + +1. **安装 WSL** + + 如果尚未安装,请参照 Microsoft 官方指南[安装 WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。 + +2. **在 WSL 中安装 OpenCode** + + WSL 设置完成后,打开 WSL 终端,使用任一[安装方式](/docs/)安装 OpenCode。 + + ```bash + curl -fsSL https://opencode.ai/install | bash + ``` + +3. **从 WSL 中使用 OpenCode** + + 导航到你的项目目录(通过 `/mnt/c/`、`/mnt/d/` 等路径访问 Windows 文件),然后运行 OpenCode。 + + ```bash + cd /mnt/c/Users/YourName/project + opencode + ``` + + + +--- + +## 桌面应用 + WSL 服务器 + +如果你希望使用 OpenCode 桌面应用,同时在 WSL 中运行服务器: + +1. **在 WSL 中启动服务器**,添加 `--hostname 0.0.0.0` 以允许外部连接: + + ```bash + opencode serve --hostname 0.0.0.0 --port 4096 + ``` + +2. **在桌面应用中连接到** `http://localhost:4096` + +:::note +如果 `localhost` 在你的环境中无法使用,请改用 WSL 的 IP 地址进行连接(在 WSL 中运行:`hostname -I`),使用 `http://:4096`。 +::: + +:::caution +使用 `--hostname 0.0.0.0` 时,请设置 `OPENCODE_SERVER_PASSWORD` 以保护服务器安全。 +::: + +```bash +OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 +``` + +--- + +## Web 客户端 + WSL + +要在 Windows 上获得最佳的 Web 体验: + +1. **在 WSL 终端中运行 `opencode web`**,而非在 PowerShell 中运行: + + ```bash + opencode web --hostname 0.0.0.0 + ``` + +2. **在 Windows 浏览器中访问** `http://localhost:`(OpenCode 会输出该 URL) + +从 WSL 中运行 `opencode web` 可确保正确的文件系统访问和终端集成,同时仍可通过 Windows 浏览器进行访问。 + +--- + +## 访问 Windows 文件 + +WSL 可以通过 `/mnt/` 目录访问你的所有 Windows 文件: + +- `C:` 盘 → `/mnt/c/` +- `D:` 盘 → `/mnt/d/` +- 其他盘符以此类推... + +示例: + +```bash +cd /mnt/c/Users/YourName/Documents/project +opencode +``` + +:::tip +为了获得更流畅的体验,建议将仓库克隆或复制到 WSL 文件系统中(例如 `~/code/` 目录下),然后在该位置运行 OpenCode。 +::: + +--- + +## 使用技巧 + +- 对于存储在 Windows 驱动器上的项目,在 WSL 中运行 OpenCode 即可无缝访问文件 +- 搭配 VS Code 的 [WSL 扩展](https://code.visualstudio.com/docs/remote/wsl) 使用 OpenCode,打造一体化的开发工作流 +- OpenCode 的配置和会话数据存储在 WSL 环境中的 `~/.local/share/opencode/` diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx new file mode 100644 index 0000000000000000000000000000000000000000..1aa21ace3ccd1426c32373a39225a90bbf076dfe --- /dev/null +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -0,0 +1,351 @@ +--- +title: Zen +description: 由 OpenCode 提供的精选模型列表。 +--- + +import config from "../../../../config.mjs" +export const console = config.console +export const email = `mailto:${config.email}` + +OpenCode Zen 是由 OpenCode 团队提供的一组经过测试和验证的模型。 + +Zen 的工作方式与 OpenCode 中的任何其他提供商相同。你登录 OpenCode Zen 并获取 API 密钥。它是**完全可选的**,即使不用它,你也可以照常使用 OpenCode。 + +--- + +## 背景 + +现在市面上有大量模型,但其中只有少数模型适合作为编码代理使用。此外,大多数提供商的配置方式差异很大,因此你获得的性能和质量也会非常不同。 + +:::tip +我们测试了一组与 OpenCode 配合良好的精选模型和提供商。 +::: + +所以,如果你通过 OpenRouter 之类的服务使用模型,你无法确定自己拿到的是否是目标模型的最佳版本。 + +为了解决这个问题,我们做了几件事: + +1. 我们测试了一组选定的模型,并与它们的团队讨论了如何以最佳方式运行这些模型。 +2. 然后我们与几家提供商合作,确保这些模型被正确提供。 +3. 最后,我们对模型和提供商的组合进行了基准测试,并整理出了一份我们认为值得推荐的列表。 + +OpenCode Zen 是一个 AI 网关,让你可以访问这些模型。 + +--- + +## 工作原理 + +OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 + +1. 登录 **OpenCode Zen**,添加你的账单信息,然后复制你的 API 密钥。 +2. 在 TUI 中运行 `/connect` 命令,选择 OpenCode Zen,然后粘贴你的 API 密钥。 +3. 在 TUI 中运行 `/models`,查看我们推荐的模型列表。 + +你按请求付费,也可以向账户充值。 + +--- + +## 端点 + +你也可以通过以下 API 端点访问我们的模型。 + +| 模型 | 模型 ID | 端点 | AI SDK 包 | +| ------------------------------- | ------------------------------- | --------------------------------------------------------- | --------------------------- | +| GPT 6 Astra | gpt-6-astra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Fable 5.1 | claude-fable-5-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 5 | claude-sonnet-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.8 Flash | gemini-3.8-flash | `https://opencode.ai/zen/v1/models/gemini-3.8-flash` | `@ai-sdk/google` | +| Gemini 3.7 Flash | gemini-3.7-flash | `https://opencode.ai/zen/v1/models/gemini-3.7-flash` | `@ai-sdk/google` | +| Gemini 3.6 Flash | gemini-3.6-flash | `https://opencode.ai/zen/v1/models/gemini-3.6-flash` | `@ai-sdk/google` | +| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | +| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.3 | muse-spark-1.3 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Muse Spark 1.2 | muse-spark-1.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Vision Exp | deepseek-v4-flash-vision-exp | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.3 Flash | glm-5.3-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.3 | glm-5.3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling 3.0 Flash Fin Free | ling-3.0-flash-fin-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3.5 Lightning Free | nemotron-3.5-lightning-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Muse Spark 1.3 Contributor Free | muse-spark-1.3-contributor-free | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | + +在你的 OpenCode 配置中,[模型 ID](/docs/config/#models) 使用 `opencode/` 格式。例如,对于 GPT 5.5,你需要在配置中使用 `opencode/gpt-5.5`。 + +--- + +### 模型 + +你可以从以下地址获取可用模型及其元数据的完整列表: + +``` +https://opencode.ai/zen/v1/models +``` + +--- + +## 定价 + +我们支持按量付费模式。以下是**每 1M tokens** 的价格。 + +| 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 | +| --------------------------------- | ------ | ------- | -------- | -------- | +| Big Pickle | Free | Free | Free | - | +| MiMo-V2.5 Free | Free | Free | Free | - | +| Ling 3.0 Flash Fin Free | Free | Free | Free | - | +| Nemotron 3 Ultra Free | Free | Free | Free | - | +| Nemotron 3.5 Lightning Free | Free | Free | Free | - | +| Muse Spark 1.3 Contributor Free | Free | Free | Free | - | +| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | - | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - | +| GLM 5.3 Flash | $0.15 | $0.50 | $0.03 | - | +| GLM 5.3 | $1.40 | $4.40 | $0.26 | - | +| GLM 5.2 | $1.40 | $4.40 | $0.26 | - | +| GLM 5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM 5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.7 Plus | $0.40 | $1.60 | $0.04 | $0.50 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | +| DeepSeek V4 Flash Vision Exp | $0.14 | $0.28 | $0.028 | - | +| Claude Fable 5.1 | $10.00 | $50.00 | $0.25 | $12.50 | +| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Sonnet 5 | $2.00 | $10.00 | $0.20 | $2.50 | +| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | +| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 | +| Gemini 3.8 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.7 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.6 Flash | $1.50 | $7.50 | $0.15 | - | +| Gemini 3.5 Flash | $1.50 | $9.00 | $0.15 | - | +| Gemini 3.5 Flash Lite | $0.30 | $2.50 | $0.03 | - | +| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | +| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | +| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | +| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | +| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | +| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - | +| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - | +| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | +| Muse Spark 1.3 | $1.25 | $4.25 | $0.15 | - | +| Muse Spark 1.2 | $1.25 | $4.25 | $0.15 | - | +| GPT 6 Astra (≤ 272K tokens) | $10.00 | $50.00 | $1.00 | $12.50 | +| GPT 6 Astra (> 272K tokens) | $20.00 | $75.00 | $2.00 | $25.00 | +| GPT 5.6 Sol (≤ 272K tokens) | $2.00 | $10.00 | $0.20 | $2.50 | +| GPT 5.6 Sol (> 272K tokens) | $4.00 | $15.00 | $0.40 | $5.00 | +| GPT 5.6 Terra (≤ 272K tokens) | $2.00 | $12.00 | $0.20 | $2.50 | +| GPT 5.6 Terra (> 272K tokens) | $4.00 | $18.00 | $0.40 | $5.00 | +| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | +| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | +| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - | +| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - | +| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - | +| GPT 5.4 (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | - | +| GPT 5.4 (> 272K tokens) | $5.00 | $22.50 | $0.50 | - | +| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - | +| GPT 5.4 Mini | $0.75 | $4.50 | $0.075 | - | +| GPT 5.4 Nano | $0.20 | $1.25 | $0.02 | - | +| GPT 5.3 Codex Spark | $1.75 | $14.00 | $0.175 | - | +| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - | +| GPT 5.2 | $1.75 | $14.00 | $0.175 | - | +| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - | +| GPT 5.1 | $1.07 | $8.50 | $0.107 | - | +| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - | +| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - | +| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | +| GPT 5 | $1.07 | $8.50 | $0.107 | - | +| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | + +**GPT 5.6 Sol:** 所示价格已包含 50% 折扣,有效期至 2026 年 9 月 18 日。 + +**DeepSeek V4 Flash Vision Exp:** 图片会根据尺寸转换为 token,并与文本 token 一起按输入 token 计费。 [了解更多](https://api-docs.deepseek.com/quick_start/pricing/)。 + +你可能会在使用记录中看到 Haiku、Nano 或 Flash 等[低成本模型](/docs/config/#models)。OpenCode 使用这些模型生成会话标题。 + +:::note +信用卡手续费按成本转嫁(每笔交易 4.4% + $0.30);除此之外我们不会额外收费。 +::: + +免费模型: + +- MiMo-V2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Ling 3.0 Flash Fin Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Nemotron 3 Ultra Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Nemotron 3.5 Lightning Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Muse Spark 1.3 Contributor Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 + +如果你有任何问题,请联系我们。 + +--- + +### 自动充值 + +如果你的余额低于 $5,Zen 将自动充值 $20。 + +你可以更改自动充值金额,也可以完全禁用自动充值。 + +--- + +### 月度限额 + +你还可以为整个工作区以及团队中的每位成员设置月度使用限额。 + +例如,假设你将月度使用限额设置为 $20,那么 Zen 在一个月内的使用金额不会超过 $20。但如果你启用了自动充值,当余额低于 $5 时,Zen 最终向你收取的金额可能会超过 $20。 + +--- + +### 已弃用模型 + +| 模型 | 弃用日期 | +| ------------------ | ----------------- | +| GPT 5.2 Codex | July 23, 2026 | +| GPT 5.1 Codex | July 23, 2026 | +| GPT 5.1 Codex Max | July 23, 2026 | +| GPT 5.1 Codex Mini | July 23, 2026 | +| GPT 5 Codex | July 23, 2026 | +| Claude Opus 4.1 | August 5, 2026 | +| Claude Sonnet 4 | June 15, 2026 | +| Claude Haiku 3.5 | February 16, 2026 | +| Gemini 3 Pro | March 9, 2026 | +| MiniMax M2.5 | August 5, 2026 | +| MiniMax M2.1 | March 15, 2026 | +| GLM 5 | May 14, 2026 | +| GLM 4.7 | March 15, 2026 | +| GLM 4.6 | March 15, 2026 | +| Kimi K2.5 | August 5, 2026 | +| Kimi K2 Thinking | March 6, 2026 | +| Kimi K2 | March 6, 2026 | +| Qwen3 Coder 480B | February 6, 2026 | + +--- + +## 隐私 + +我们所有模型都托管在 US。我们的提供商遵循零保留政策,不会将你的数据用于模型训练,但以下情况除外: + +- Big Pickle:在免费期间,收集的数据可能会被用于改进模型。 +- MiMo-V2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 +- Ling 3.0 Flash Fin Free:在免费期间,收集的数据可能会被用于改进模型。 +- Nemotron 3 Ultra Free(NVIDIA 免费端点):仅供试用 — 请勿提交个人或机密数据。出于安全目的以及为改进 NVIDIA 产品和服务,系统会记录你的使用情况。出于改进目的而记录的会话数据不会与你的身份或任何持久标识符相关联。有关我们数据处理实践的更多信息,请参阅我们的[隐私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。与此端点进行交互,即表示你同意我们收集、记录和使用此类信息,并同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。 +- Nemotron 3.5 Lightning Free(NVIDIA 免费端点):仅供试用 — 请勿提交个人或机密数据。出于安全目的以及为改进 NVIDIA 产品和服务,系统会记录你的使用情况。出于改进目的而记录的会话数据不会与你的身份或任何持久标识符相关联。有关我们数据处理实践的更多信息,请参阅我们的[隐私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。与此端点进行交互,即表示你同意我们收集、记录和使用此类信息,并同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。 +- OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 +- Anthropic APIs:请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 +- Muse Spark 1.3 Contributor Free:以允许使用你的提示词和补全内容训练未来的 Meta 模型为条件,享受大幅折扣的 token 价格。[了解更多](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier)。 + +--- + +## 团队 + +Zen 也非常适合团队使用。你可以邀请队友、分配角色、管理团队使用的模型,等等。 + +:::note +作为测试版的一部分,工作区目前对团队免费开放。 +::: + +作为测试版的一部分,团队目前可以免费管理工作区。我们很快会分享更多定价细节。 + +--- + +### 角色 + +你可以邀请队友加入工作区并分配角色: + +- **Admin**:管理模型、成员、API 密钥和账单 +- **Member**:仅管理自己的 API 密钥 + +Admin 还可以为每位成员设置月度支出限额,以便控制成本。 + +--- + +### 模型访问 + +Admin 可以为工作区启用或禁用特定模型。向已禁用模型发出的请求会返回错误。 + +这在你想禁用会收集数据的模型时很有用。 + +--- + +### 自带密钥 + +你可以使用自己的 OpenAI 或 Anthropic API 密钥,同时仍然访问 Zen 中的其他模型。 + +当你使用自己的密钥时,tokens 由提供商直接计费,而不是由 Zen 计费。 + +例如,你的组织可能已经拥有 OpenAI 或 Anthropic 的密钥,并且你想使用它,而不是使用 Zen 提供的密钥。 + +--- + +## 目标 + +我们创建 OpenCode Zen,是为了: + +1. 为编码代理**基准测试**最佳模型和提供商。 +2. 提供**最高质量**的选项,而不是降低性能或路由到更便宜的提供商。 +3. 通过按成本销售来传递任何**降价**;因此唯一的加价只是为了覆盖我们的处理费用。 +4. 保持**无锁定**,允许你将它与任何其他编码代理一起使用。同时也始终允许你在 OpenCode 中使用任何其他提供商。 diff --git a/packages/web/src/content/docs/zh-tw/acp.mdx b/packages/web/src/content/docs/zh-tw/acp.mdx new file mode 100644 index 0000000000000000000000000000000000000000..0d2f7ebae27d3c7508e534b73c9856fdd65abd10 --- /dev/null +++ b/packages/web/src/content/docs/zh-tw/acp.mdx @@ -0,0 +1,159 @@ +--- +title: ACP 支援 +description: 在任何相容 ACP 的編輯器中使用 OpenCode。 +--- + +OpenCode 支援 [Agent Client Protocol](https://agentclientprotocol.com)(ACP),允許你直接在相容的編輯器和 IDE 中使用它。 + +:::tip +有關支援 ACP 的編輯器和工具列表,請查看 [ACP 進展報告](https://zed.dev/blog/acp-progress-report#available-now)。 +::: + +ACP 是一個開放協議,用於標準化程式碼編輯器與 AI 編碼代理之間的通訊。 + +--- + +## 設定 + +要透過 ACP 使用 OpenCode,請在編輯器中設定執行 `opencode acp` 命令。 + +該命令會將 OpenCode 作為相容 ACP 的子程序啟動,透過 stdio 上的 JSON-RPC 與編輯器進行通訊。 + +以下是支援 ACP 的常用編輯器的設定範例。 + +--- + +### Zed + +在命令面板中執行 `zed: acp registry`,從 [Zed ACP 登錄檔](https://zed.dev/docs/ai/external-agents#registry)安裝 OpenCode。 + +如果要改用自訂 OpenCode 執行檔,請將它新增到 [Zed](https://zed.dev) 設定檔(`~/.config/zed/settings.json`)中: + +```json title="~/.config/zed/settings.json" +{ + "agent_servers": { + "OpenCode": { + "type": "custom", + "command": "opencode", + "args": ["acp"] + } + } +} +``` + +開啟方式:在**命令面板**中執行 `agent: new thread` 操作。 + +你也可以透過編輯 `keymap.json` 來繫結鍵盤快速鍵: + +```json title="keymap.json" +[ + { + "bindings": { + "cmd-alt-o": [ + "agent::NewExternalAgentThread", + { + "agent": { + "custom": { + "name": "OpenCode", + "command": { + "command": "opencode", + "args": ["acp"] + } + } + } + } + ] + } + } +] +``` + +--- + +### JetBrains IDEs + +根據[文件](https://www.jetbrains.com/help/ai-assistant/acp.html),將以下內容新增到你的 [JetBrains IDE](https://www.jetbrains.com/) 的 acp.json 中: + +```json title="acp.json" +{ + "agent_servers": { + "OpenCode": { + "command": "/absolute/path/bin/opencode", + "args": ["acp"] + } + } +} +``` + +開啟方式:在 AI Chat 代理選擇器中選擇新的 'OpenCode' 代理。 + +--- + +### Avante.nvim + +新增到你的 [Avante.nvim](https://github.com/yetone/avante.nvim) 設定中: + +```lua +{ + acp_providers = { + ["opencode"] = { + command = "opencode", + args = { "acp" } + } + } +} +``` + +如果需要傳遞環境變數: + +```lua {6-8} +{ + acp_providers = { + ["opencode"] = { + command = "opencode", + args = { "acp" }, + env = { + OPENCODE_API_KEY = os.getenv("OPENCODE_API_KEY") + } + } + } +} +``` + +--- + +### CodeCompanion.nvim + +要在 [CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim) 中將 OpenCode 用作 ACP 代理,請將以下內容新增到你的 Neovim 設定中: + +```lua +require("codecompanion").setup({ + interactions = { + chat = { + adapter = { + name = "opencode", + model = "claude-sonnet-4", + }, + }, + }, +}) +``` + +此設定將 CodeCompanion 設為使用 OpenCode 作為聊天的 ACP 代理。 + +如果需要傳遞環境變數(如 `OPENCODE_API_KEY`),請參閱 CodeCompanion.nvim 文件中的[設定適配器:環境變數](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key)了解詳細資訊。 + +## 支援 + +OpenCode 透過 ACP 使用時與在終端機中使用的效果完全一致。所有功能均受支援: + +:::note +部分內建斜線命令(如 `/undo` 和 `/redo`)目前暫不支援。 +::: + +- 內建工具(檔案操作、終端機命令等) +- 自訂工具和斜線命令 +- 在 OpenCode 設定中設定的 MCP 伺服器 +- 來自 `AGENTS.md` 的專案級規則 +- 自訂格式化工具和程式碼檢查工具 +- 代理和權限系統 diff --git a/packages/web/src/content/docs/zh-tw/agents.mdx b/packages/web/src/content/docs/zh-tw/agents.mdx new file mode 100644 index 0000000000000000000000000000000000000000..a9c7bbadbf23b02f96bb2552db35adcc8b980893 --- /dev/null +++ b/packages/web/src/content/docs/zh-tw/agents.mdx @@ -0,0 +1,754 @@ +--- +title: 代理 +description: 設定和使用專門的代理。 +--- + +代理是專門的 AI 助手,可以針對特定任務和工作流程進行設定。它們允許您建立具有自訂提示詞、模型和工具存取權限的專用工具。 + +:::tip +使用 Plan 代理來分析程式碼和審查建議,而不會進行任何程式碼變更。 +::: + +您可以在工作階段期間切換代理,或使用 `@` 提及來呼叫它們。 + +--- + +## 類型 + +OpenCode 中有兩種類型的代理:主代理和子代理。 + +--- + +### 主代理 + +主代理是您直接互動的主要助手。您可以使用 **Tab** 鍵或設定的 `switch_agent` 快速鍵來循環切換它們。這些代理處理您的主要對話。工具存取透過權限進行設定——例如,Build 啟用了所有工具,而 Plan 則受到限制。 + +:::tip +您可以在工作階段期間使用 **Tab** 鍵在主代理之間切換。 +::: + +OpenCode 內建了兩個主代理:**Build** 和 **Plan**。我們將在下面介紹它們。 + +--- + +### 子代理 + +子代理是主代理可以呼叫來執行特定任務的專業助手。您也可以透過在訊息中 **@ 提及**它們來手動呼叫。 + +OpenCode 內建了三個子代理:**General**、**Explore** 和 **Scout**。我們將在下面介紹它們。 + +--- + +## 內建代理 + +OpenCode 內建了兩個主代理和三個子代理。 + +--- + +### 使用 Build + +_模式_:`primary` + +Build 是啟用了所有工具的**預設**主代理。這是用於需要完全存取檔案操作和系統命令的開發工作的標準代理。 + +--- + +### 使用 Plan + +_模式_:`primary` + +一個專為規劃和分析設計的受限代理。我們使用權限系統來為您提供更多控制權,並防止意外變更。 +預設情況下,以下所有項均設為 `ask`: + +- `file edits`:所有寫入、補丁和編輯 +- `bash`:所有 bash 命令 + +當您希望 LLM 分析程式碼、建議變更或建立計畫,而不對程式碼庫進行任何實際修改時,此代理非常有用。 + +--- + +### 使用 General + +_模式_:`subagent` + +一個用於研究複雜問題和執行多步驟任務的通用代理。擁有完整的工具存取權限(todo 除外),因此可以在需要時修改檔案。可用於並行執行多個工作單元。 + +--- + +### 使用 Explore + +_模式_:`subagent` + +一個用於探索程式碼庫的快速唯讀代理。無法修改檔案。當您需要按模式快速查找檔案、搜尋程式碼中的關鍵字或回答有關程式碼庫的問題時,請使用此代理。 + +--- + +### 使用 Scout + +_模式_:`subagent` + +一個用於外部文件與依賴研究的唯讀代理。當您需要將某個依賴儲存庫 clone 到 OpenCode 的託管快取中、檢查函式庫的原始碼,或在不修改工作區的情況下將本機程式碼與 upstream 實作交叉比對時,請使用此代理。 + +--- + +### 使用 Compaction + +_模式_:`primary` + +隱藏的系統代理,將長上下文壓縮為較小的摘要。它會在需要時自動執行,且無法在 UI 中選擇。 + +--- + +### 使用 Title + +_模式_:`primary` + +隱藏的系統代理,用於產生簡短的工作階段標題。它會自動執行,且無法在 UI 中選擇。 + +--- + +### 使用 Summary + +_模式_:`primary` + +隱藏的系統代理,用於建立工作階段摘要。它會自動執行,且無法在 UI 中選擇。 + +--- + +## 用法 + +1. 對於主代理,在工作階段期間使用 **Tab** 鍵循環切換。您也可以使用設定的 `switch_agent` 快速鍵。 + +2. 子代理可以透過以下方式呼叫: + - 由主代理根據其描述**自動**呼叫以執行專門任務。 + - 透過在訊息中 **@ 提及**子代理來手動呼叫。例如: + + ```txt frame="none" + @general help me search for this function + ``` + +3. **工作階段間導覽**:當子代理建立自己的子工作階段時,您可以使用以下方式在父工作階段和所有子工作階段之間導覽: + - **\+Right**(或設定的 `session_child_cycle` 快速鍵)向前循環:父工作階段 → 子工作階段1 → 子工作階段2 → ... → 父工作階段 + - **\+Left**(或設定的 `session_child_cycle_reverse` 快速鍵)向後循環:父工作階段 ← 子工作階段1 ← 子工作階段2 ← ... ← 父工作階段 + + 這使您可以在主對話和專門的子代理工作之間無縫切換。 + +--- + +## 設定 + +您可以自訂內建代理或透過設定建立自己的代理。代理可以透過兩種方式進行設定: + +--- + +### JSON + +在 `opencode.json` 設定檔中設定代理: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "mode": "primary", + "model": "anthropic/claude-sonnet-4-20250514", + "prompt": "{file:./prompts/build.txt}", + "tools": { + "write": true, + "edit": true, + "bash": true + } + }, + "plan": { + "mode": "primary", + "model": "anthropic/claude-haiku-4-20250514", + "tools": { + "write": false, + "edit": false, + "bash": false + } + }, + "code-reviewer": { + "description": "Reviews code for best practices and potential issues", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-20250514", + "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", + "tools": { + "write": false, + "edit": false + } + } + } +} +``` + +--- + +### Markdown + +您還可以使用 Markdown 檔案定義代理。將它們放在: + +- 全域:`~/.config/opencode/agents/` +- 專案級:`.opencode/agents/` + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Reviews code for quality and best practices +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +temperature: 0.1 +tools: + write: false + edit: false + bash: false +--- + +You are in code review mode. Focus on: + +- Code quality and best practices +- Potential bugs and edge cases +- Performance implications +- Security considerations + +Provide constructive feedback without making direct changes. +``` + +Markdown 檔案名稱即為代理名稱。例如,`review.md` 會建立一個名為 `review` 的代理。 + +--- + +## 選項 + +讓我們詳細了解這些設定選項。 + +--- + +### 描述 + +使用 `description` 選項提供代理的功能及使用場景的簡要描述。 + +```json title="opencode.json" +{ + "agent": { + "review": { + "description": "Reviews code for best practices and potential issues" + } + } +} +``` + +這是一個**必需的**設定選項。 + +--- + +### 溫度 + +使用 `temperature` 設定控制 LLM 回應的隨機性和創造力。 + +較低的值使回應更加集中和確定,而較高的值則增加創造力和多樣性。 + +```json title="opencode.json" +{ + "agent": { + "plan": { + "temperature": 0.1 + }, + "creative": { + "temperature": 0.8 + } + } +} +``` + +溫度值通常範圍為 0.0 到 1.0: + +- **0.0-0.2**:非常集中和確定性的回應,適合程式碼分析和規劃 +- **0.3-0.5**:平衡的回應,兼顧一定創造力,適合一般開發任務 +- **0.6-1.0**:更有創造力和多樣性的回應,適合腦力激盪和探索 + +```json title="opencode.json" +{ + "agent": { + "analyze": { + "temperature": 0.1, + "prompt": "{file:./prompts/analysis.txt}" + }, + "build": { + "temperature": 0.3 + }, + "brainstorm": { + "temperature": 0.7, + "prompt": "{file:./prompts/creative.txt}" + } + } +} +``` + +如果未指定溫度,OpenCode 將使用模型特定的預設值;大多數模型通常為 0,Qwen 模型為 0.55。 + +--- + +### 最大步數 + +控制代理在被強制以純文字回應之前可以執行的最大代理迭代次數。這允許希望控制成本的使用者對代理操作設定限制。 + +如果未設定此選項,代理將持續迭代,直到模型選擇停止或使用者中斷工作階段。 + +```json title="opencode.json" +{ + "agent": { + "quick-thinker": { + "description": "Fast reasoning with limited iterations", + "prompt": "You are a quick thinker. Solve problems with minimal steps.", + "steps": 5 + } + } +} +``` + +當達到限制時,代理會收到一個特殊的系統提示詞,指示其回覆工作摘要和建議的剩餘任務。 + +:::caution +舊版 `maxSteps` 欄位已棄用。請改用 `steps`。 +::: + +--- + +### 停用 + +設為 `true` 以停用代理。 + +```json title="opencode.json" +{ + "agent": { + "review": { + "disable": true + } + } +} +``` + +--- + +### 提示詞 + +使用 `prompt` 設定為代理指定自訂系統提示詞檔案。提示詞檔案應包含針對代理用途的具體指令。 + +```json title="opencode.json" +{ + "agent": { + "review": { + "prompt": "{file:./prompts/code-review.txt}" + } + } +} +``` + +此路徑相對於設定檔所在位置。因此它同時適用於全域 OpenCode 設定和專案級設定。 + +--- + +### 模型 + +使用 `model` 設定為代理覆寫模型。適用於針對不同任務使用不同的最佳化模型。例如,用更快的模型進行規劃,用更強大的模型進行實作。 + +:::tip +如果您不指定模型,主代理將使用[全域設定的模型](/docs/config#models),而子代理將使用呼叫它的主代理所使用的模型。 +::: + +```json title="opencode.json" +{ + "agent": { + "plan": { + "model": "anthropic/claude-haiku-4-20250514" + } + } +} +``` + +OpenCode 設定中的模型 ID 使用 `provider/model-id` 格式。例如,如果您使用 [OpenCode Zen](/docs/zen),則可以使用 `opencode/gpt-5.1-codex` 來表示 GPT 5.1 Codex。 + +--- + +### 工具 + +使用 `tools` 設定控制代理中可用的工具。您可以透過將特定工具設為 `true` 或 `false` 來啟用或停用它們。 + +```json title="opencode.json" {3-6,9-12} +{ + "$schema": "https://opencode.ai/config.json", + "tools": { + "write": true, + "bash": true + }, + "agent": { + "plan": { + "tools": { + "write": false, + "bash": false + } + } + } +} +``` + +:::note +代理級設定會覆寫全域設定。 +::: + +您還可以使用萬用字元同時控制多個工具。例如,要停用 MCP 伺服器中的所有工具: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "readonly": { + "tools": { + "mymcp_*": false, + "write": false, + "edit": false + } + } + } +} +``` + +[了解更多關於工具的資訊](/docs/tools)。 + +--- + +### 權限 + +您可以設定權限來管理代理可以執行的操作。目前,`edit`、`bash` 和 `webfetch` 工具的權限可以設定為: + +- `"ask"` — 執行工具前提示審批 +- `"allow"` — 允許所有操作,無需審批 +- `"deny"` — 停用該工具 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny" + } +} +``` + +您可以按代理覆寫這些權限。 + +```json title="opencode.json" {3-5,8-10} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny" + }, + "agent": { + "build": { + "permission": { + "edit": "ask" + } + } + } +} +``` + +您還可以在 Markdown 代理中設定權限。 + +```markdown title="~/.config/opencode/agents/review.md" +--- +description: Code review without edits +mode: subagent +permission: + edit: deny + bash: + "*": ask + "git diff": allow + "git log*": allow + "grep *": allow + webfetch: deny +--- + +Only analyze code and suggest changes. +``` + +您可以為特定的 bash 命令設定權限。 + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "git push": "ask", + "grep *": "allow" + } + } + } + } +} +``` + +這可以使用 glob 模式。 + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "git *": "ask" + } + } + } + } +} +``` + +您還可以使用 `*` 萬用字元來管理所有命令的權限。 +由於最後匹配的規則優先,請將 `*` 萬用字元放在前面,將具體規則放在後面。 + +```json title="opencode.json" {8} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git status *": "allow" + } + } + } + } +} +``` + +[了解更多關於權限的資訊](/docs/permissions)。 + +--- + +### 模式 + +使用 `mode` 設定控制代理的模式。`mode` 選項用於確定代理的使用方式。 + +```json title="opencode.json" +{ + "agent": { + "review": { + "mode": "subagent" + } + } +} +``` + +`mode` 選項可以設為 `primary`、`subagent` 或 `all`。如果未指定 `mode`,則預設為 `all`。 + +--- + +### 隱藏 + +使用 `hidden: true` 將子代理從 `@` 自動補全選單中隱藏。適用於只應由其他代理透過 Task 工具以程式化方式呼叫的內部子代理。 + +```json title="opencode.json" +{ + "agent": { + "internal-helper": { + "mode": "subagent", + "hidden": true + } + } +} +``` + +這僅影響自動補全選單中的使用者可見性。如果權限允許,模型仍然可以透過 Task 工具呼叫隱藏的代理。 + +:::note +僅適用於 `mode: subagent` 的代理。 +::: + +--- + +### 任務權限 + +使用 `permission.task` 控制代理可以透過 Task 工具呼叫哪些子代理。使用 glob 模式進行靈活匹配。 + +```json title="opencode.json" +{ + "agent": { + "orchestrator": { + "mode": "primary", + "permission": { + "task": { + "*": "deny", + "orchestrator-*": "allow", + "code-reviewer": "ask" + } + } + } + } +} +``` + +當設為 `deny` 時,子代理將從 Task 工具描述中完全移除,因此模型不會嘗試呼叫它。 + +:::tip +規則按順序評估,**最後匹配的規則優先**。在上面的範例中,`orchestrator-planner` 同時匹配 `*`(deny)和 `orchestrator-*`(allow),但由於 `orchestrator-*` 在 `*` 之後,所以結果為 `allow`。 +::: + +:::tip +使用者始終可以透過 `@` 自動補全選單直接呼叫任何子代理,即使代理的任務權限會拒絕它。 +::: + +--- + +### 顏色 + +使用 `color` 選項自訂代理在 UI 中的視覺外觀。這會影響代理在介面中的顯示方式。 + +使用有效的十六進位顏色(例如 `#FF5733`)或主題顏色:`primary`、`secondary`、`accent`、`success`、`warning`、`error`、`info`。 + +```json title="opencode.json" +{ + "agent": { + "creative": { + "color": "#ff6b6b" + }, + "code-reviewer": { + "color": "accent" + } + } +} +``` + +--- + +### Top P + +使用 `top_p` 選項控制回應多樣性。這是控制隨機性的溫度替代方案。 + +```json title="opencode.json" +{ + "agent": { + "brainstorm": { + "top_p": 0.9 + } + } +} +``` + +值範圍從 0.0 到 1.0。較低的值更加集中,較高的值更加多樣化。 + +--- + +### 其他選項 + +您在代理設定中指定的任何其他選項都將作為模型選項**直接傳遞**給提供商。這允許您使用提供商特定的功能和參數。 + +例如,使用 OpenAI 的推理模型時,您可以控制推理力度: + +```json title="opencode.json" {6,7} +{ + "agent": { + "deep-thinker": { + "description": "Agent that uses high reasoning effort for complex problems", + "model": "openai/gpt-5", + "reasoningEffort": "high", + "textVerbosity": "low" + } + } +} +``` + +這些附加選項是模型和提供商特定的。請查閱您的提供商文件以取得可用參數。 + +:::tip +執行 `opencode models` 查看可用模型列表。 +::: + +--- + +## 建立代理 + +您可以使用以下命令建立新代理: + +```bash +opencode agent create +``` + +此互動式命令將: + +1. 詢問代理的儲存位置——全域或專案級。 +2. 描述代理應該做什麼。 +3. 產生合適的系統提示詞和識別碼。 +4. 讓您選擇代理可以存取哪些工具。 +5. 最後,建立一個包含代理設定的 Markdown 檔案。 + +--- + +## 使用場景 + +以下是不同代理的一些常見使用場景。 + +- **Build 代理**:啟用所有工具的完整開發工作 +- **Plan 代理**:分析和規劃,不進行任何變更 +- **Review 代理**:具有唯讀存取權限和文件工具的程式碼審查 +- **Debug 代理**:專注於問題排查,啟用 bash 和讀取工具 +- **Docs 代理**:文件編寫,具有檔案操作但不使用系統命令 + +--- + +## 範例 + +以下是一些您可能會覺得有用的範例代理。 + +:::tip +您有想要分享的代理嗎?[提交 PR](https://github.com/anomalyco/opencode)。 +::: + +--- + +### 文件代理 + +```markdown title="~/.config/opencode/agents/docs-writer.md" +--- +description: Writes and maintains project documentation +mode: subagent +tools: + bash: false +--- + +You are a technical writer. Create clear, comprehensive documentation. + +Focus on: + +- Clear explanations +- Proper structure +- Code examples +- User-friendly language +``` + +--- + +### 安全稽核代理 + +```markdown title="~/.config/opencode/agents/security-auditor.md" +--- +description: Performs security audits and identifies vulnerabilities +mode: subagent +tools: + write: false + edit: false +--- + +You are a security expert. Focus on identifying potential security issues. + +Look for: + +- Input validation vulnerabilities +- Authentication and authorization flaws +- Data exposure risks +- Dependency vulnerabilities +- Configuration security issues +``` diff --git a/packages/web/src/content/docs/zh-tw/cli.mdx b/packages/web/src/content/docs/zh-tw/cli.mdx new file mode 100644 index 0000000000000000000000000000000000000000..25e7bce88e41629c550d20474135fd59b56b5398 --- /dev/null +++ b/packages/web/src/content/docs/zh-tw/cli.mdx @@ -0,0 +1,617 @@ +--- +title: CLI +description: OpenCode CLI 選項和指令。 +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" + +OpenCode CLI 在不帶任何參數執行時,預設啟動 [TUI](/docs/tui)。 + +```bash +opencode +``` + +但它也接受本頁面中記錄的指令,使您可以透過程式方式與 OpenCode 進行互動。 + +```bash +opencode run "Explain how closures work in JavaScript" +``` + +--- + +### tui + +啟動 OpenCode 終端機使用者介面。 + +```bash +opencode [project] +``` + +#### 旗標 + +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ------------------------------------------------------------- | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | +| {"--prompt"} | | 要使用的提示詞 | +| {"--model"} | `-m` | 要使用的模型,格式為 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--port"} | | 監聽連接埠 | +| {"--hostname"} | | 監聽主機名稱 | + +--- + +## 指令 + +OpenCode CLI 還提供以下指令。 + +--- + +### agent + +管理 OpenCode 的代理。 + +```bash +opencode agent [command] +``` + +--- + +### attach + +將終端機連接到已透過 `serve` 或 `web` 指令啟動的 OpenCode 後端伺服器。 + +```bash +opencode attach [url] +``` + +這允許將 TUI 與遠端 OpenCode 後端搭配使用。例如: + +```bash +# Start the backend server for web/mobile access +opencode web --port 4096 --hostname 0.0.0.0 + +# In another terminal, attach the TUI to the running backend +opencode attach http://10.20.30.40:4096 +``` + +#### 旗標 + +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------- | +| {"--dir"} | | 啟動 TUI 的工作目錄 | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分支工作階段(與 `--continue` 或 `--session` 一起使用) | +| {"--password"} | `-p` | 基本驗證密碼(預設使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本驗證使用者名稱(預設使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | + +--- + +#### create + +使用自訂設定建立新的代理。 + +```bash +opencode agent create +``` + +此指令將引導您使用自訂系統提示詞和工具設定來建立新的代理。 + +--- + +#### list + +列出所有可用的代理。 + +```bash +opencode agent list +``` + +--- + +### auth + +管理供應商的憑證和登入資訊的指令。 + +```bash +opencode auth [command] +``` + +--- + +#### login + +OpenCode 基於 [Models.dev](https://models.dev) 的供應商列表運作,因此您可以使用 `opencode auth login` 為任何想要使用的供應商設定 API 金鑰。金鑰儲存在 `~/.local/share/opencode/auth.json` 中。 + +```bash +opencode auth login +``` + +OpenCode 啟動時會從憑證檔案載入供應商資訊,同時也會載入環境變數或專案中 `.env` 檔案中定義的金鑰。 + +--- + +#### list + +列出憑證檔案中儲存的所有已認證供應商。 + +```bash +opencode auth list +``` + +或使用簡寫版本。 + +```bash +opencode auth ls +``` + +--- + +#### logout + +從憑證檔案中清除供應商資訊以完成登出。 + +```bash +opencode auth logout +``` + +--- + +### github + +管理用於儲存庫自動化的 GitHub 代理。 + +```bash +opencode github [command] +``` + +--- + +#### install + +在您的儲存庫中安裝 GitHub 代理。 + +```bash +opencode github install +``` + +此指令會設定必要的 GitHub Actions 工作流程並引導您完成設定過程。[了解更多](/docs/github)。 + +--- + +#### run + +執行 GitHub 代理。通常在 GitHub Actions 中使用。 + +```bash +opencode github run +``` + +##### 旗標 + +| 旗標 | 說明 | +| ------------------------------------- | ------------------------------ | +| {"--event"} | 用於執行代理的 GitHub 模擬事件 | +| {"--token"} | GitHub 個人存取權杖 | + +--- + +### mcp + +管理 Model Context Protocol 伺服器。 + +```bash +opencode mcp [command] +``` + +--- + +#### add + +將 MCP 伺服器新增到您的設定中。 + +```bash +opencode mcp add +``` + +此指令將引導您新增本地或遠端 MCP 伺服器。 + +--- + +#### list + +列出所有已設定的 MCP 伺服器及其連線狀態。 + +```bash +opencode mcp list +``` + +或使用簡寫版本。 + +```bash +opencode mcp ls +``` + +--- + +#### auth + +對支援 OAuth 的 MCP 伺服器進行認證。 + +```bash +opencode mcp auth [name] +``` + +如果您不提供伺服器名稱,系統將提示您從可用的支援 OAuth 的伺服器中進行選擇。 + +您還可以列出支援 OAuth 的伺服器及其認證狀態。 + +```bash +opencode mcp auth list +``` + +或使用簡寫版本。 + +```bash +opencode mcp auth ls +``` + +--- + +#### logout + +移除 MCP 伺服器的 OAuth 憑證。 + +```bash +opencode mcp logout [name] +``` + +--- + +#### debug + +偵錯 MCP 伺服器的 OAuth 連線問題。 + +```bash +opencode mcp debug +``` + +--- + +### models + +列出已設定供應商的所有可用模型。 + +```bash +opencode models [provider] +``` + +此指令以 `provider/model` 的格式顯示所有已設定供應商中可用的模型。 + +這對於確定在[設定檔](/docs/config/)中使用的確切模型名稱非常有用。 + +您可以選擇傳入供應商 ID 來按供應商篩選模型。 + +```bash +opencode models anthropic +``` + +#### 旗標 + +| 旗標 | 說明 | +| --------------------------------------- | ------------------------------------------ | +| {"--refresh"} | 從 models.dev 重新整理模型快取 | +| {"--verbose"} | 使用更詳細的模型輸出(包含費用等中繼資料) | + +使用 `--refresh` 旗標可以更新快取的模型列表。當供應商新增了模型並且您希望在 OpenCode 中看到它們時,此功能非常有用。 + +```bash +opencode models --refresh +``` + +--- + +### run + +以非互動模式執行 OpenCode,直接傳入提示詞。 + +```bash +opencode run [message..] +``` + +這對於指令碼編寫、自動化或無需啟動完整 TUI 即可快速取得答案的情境非常有用。例如: + +```bash "opencode run" +opencode run Explain the use of context in Go +``` + +您還可以連接到正在執行的 `opencode serve` 實例,以避免每次執行時 MCP 伺服器的冷啟動時間: + +```bash +# Start a headless server in one terminal +opencode serve + +# In another terminal, run commands that attach to it +opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" +``` + +#### 旗標 + +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------- | +| {"--command"} | | 要執行的指令,使用 message 作為參數 | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | +| {"--share"} | | 分享工作階段 | +| {"--model"} | `-m` | 要使用的模型,格式為 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--file"} | `-f` | 附加到訊息的檔案 | +| {"--format"} | | 格式:default(格式化輸出)或 json(原始 JSON 事件) | +| {"--title"} | | 工作階段標題(未提供值時使用截斷的提示詞) | +| {"--attach"} | | 連接到正在執行的 opencode 伺服器(例如 http://localhost:4096) | +| {"--password"} | `-p` | 基本驗證密碼(預設使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本驗證使用者名稱(預設使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | +| {"--dir"} | | 執行目錄,或附加時遠端伺服器上的路徑 | +| {"--variant"} | | 模型變體(特定於提供者的推理級別) | +| {"--thinking"} | | 顯示思考區塊 | +| {"--port"} | | 本地伺服器連接埠(預設為隨機連接埠) | + +--- + +### serve + +啟動無介面的 OpenCode 伺服器以提供 API 存取。查看[伺服器文件](/docs/server)了解完整的 HTTP 介面。 + +```bash +opencode serve +``` + +此指令啟動一個 HTTP 伺服器,提供對 OpenCode 功能的 API 存取,無需 TUI 介面。設定 `OPENCODE_SERVER_PASSWORD` 可啟用 HTTP 基本認證(使用者名稱預設為 `opencode`)。 + +#### 旗標 + +| 旗標 | 說明 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | +| {"--mdns"} | 啟用 mDNS 探索 | +| {"--cors"} | 允許 CORS 的額外瀏覽器來源 | + +--- + +### session + +管理 OpenCode 工作階段。 + +```bash +opencode session [command] +``` + +--- + +#### list + +列出所有 OpenCode 工作階段。 + +```bash +opencode session list +``` + +##### 旗標 + +| 旗標 | 簡寫 | 說明 | +| ----------------------------------------- | ---- | ------------------------------------- | +| {"--max-count"} | `-n` | 限制為最近 N 個工作階段 | +| {"--format"} | | 輸出格式:table 或 json(預設 table) | + +--- + +### stats + +顯示 OpenCode 工作階段的 Token 用量和費用統計資訊。 + +```bash +opencode stats +``` + +#### 旗標 + +| 旗標 | 說明 | +| --------------------------------------- | ---------------------------------------------------- | +| {"--days"} | 顯示最近 N 天的統計資訊(預設為所有時間) | +| {"--tools"} | 顯示的工具數量(預設為全部) | +| {"--models"} | 顯示模型用量明細(預設隱藏)。傳入數字可顯示前 N 個 | +| {"--project"} | 按專案篩選(預設為所有專案,傳入空字串表示當前專案) | + +--- + +### export + +將工作階段資料匯出為 JSON。 + +```bash +opencode export [sessionID] +``` + +如果您不提供工作階段 ID,系統將提示您從可用的工作階段中進行選擇。 + +--- + +### import + +從 JSON 檔案或 OpenCode 分享連結匯入工作階段資料。 + +```bash +opencode import +``` + +您可以從本地檔案或 OpenCode 分享連結匯入。 + +```bash +opencode import session.json +opencode import https://opncd.ai/s/abc123 +``` + +--- + +### web + +啟動帶有 Web 介面的無介面 OpenCode 伺服器。 + +```bash +opencode web +``` + +此指令啟動一個 HTTP 伺服器並開啟瀏覽器,透過 Web 介面存取 OpenCode。設定 `OPENCODE_SERVER_PASSWORD` 可啟用 HTTP 基本認證(使用者名稱預設為 `opencode`)。 + +#### 旗標 + +| 旗標 | 說明 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | +| {"--mdns"} | 啟用 mDNS 探索 | +| {"--cors"} | 允許 CORS 的額外瀏覽器來源 | + +--- + +### acp + +啟動 ACP(Agent Client Protocol)伺服器。 + +```bash +opencode acp +``` + +此指令啟動一個透過 stdin/stdout 使用 nd-JSON 進行通訊的 ACP 伺服器。 + +#### 旗標 + +| 旗標 | 說明 | +| ---------------------------------------- | ------------ | +| {"--cwd"} | 工作目錄 | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | + +--- + +### uninstall + +解除安裝 OpenCode 並刪除所有相關檔案。 + +```bash +opencode uninstall +``` + +#### 旗標 + +| 旗標 | 簡寫 | 說明 | +| ------------------------------------------- | ---- | ------------------------------ | +| {"--keep-config"} | `-c` | 保留設定檔 | +| {"--keep-data"} | `-d` | 保留工作階段資料和快照 | +| {"--dry-run"} | | 顯示將被刪除的內容但不實際刪除 | +| {"--force"} | `-f` | 跳過確認提示 | + +--- + +### upgrade + +將 OpenCode 更新到最新版本或指定版本。 + +```bash +opencode upgrade [target] +``` + +更新到最新版本。 + +```bash +opencode upgrade +``` + +更新到指定版本。 + +```bash +opencode upgrade v0.1.48 +``` + +#### 旗標 + +| 旗標 | 簡寫 | 說明 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 使用的安裝方式:curl、npm、pnpm、bun、brew | + +--- + +## 全域旗標 + +OpenCode CLI 接受以下全域旗標。 + +| 旗標 | 簡寫 | 說明 | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | 顯示說明資訊 | +| {"--version"} | `-v` | 印出版本號 | +| {"--print-logs"} | | 將日誌輸出到 stderr | +| {"--log-level"} | | 日誌等級(DEBUG、INFO、WARN、ERROR) | + +--- + +## 環境變數 + +OpenCode 可以透過環境變數進行設定。 + +| 變數 | 類型 | 說明 | +| ------------------------------------- | ------- | ------------------------------------------- | +| `OPENCODE_AUTO_SHARE` | boolean | 自動分享工作階段 | +| `OPENCODE_GIT_BASH_PATH` | string | Windows 上 Git Bash 可執行檔的路徑 | +| `OPENCODE_CONFIG` | string | 設定檔路徑 | +| `OPENCODE_TUI_CONFIG` | string | TUI 設定檔路徑 | +| `OPENCODE_CONFIG_DIR` | string | 設定目錄路徑 | +| `OPENCODE_CONFIG_CONTENT` | string | 內嵌 JSON 設定內容 | +| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | 停用自動更新檢查 | +| `OPENCODE_DISABLE_PRUNE` | boolean | 停用舊資料清理 | +| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | 停用自動終端機標題更新 | +| `OPENCODE_PERMISSION` | string | 內嵌 JSON 權限設定 | +| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | 停用預設外掛程式 | +| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | 停用 LSP 伺服器自動下載 | +| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | 啟用實驗性模型 | +| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | 停用自動上下文壓縮 | +| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | 停用讀取 `.claude`(提示詞 + 技能) | +| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | 停用讀取 `~/.claude/CLAUDE.md` | +| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | 停用載入 `.claude/skills` | +| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | 停用從遠端來源擷取模型 | +| `OPENCODE_FAKE_VCS` | string | 用於測試目的的模擬 VCS 供應商 | +| `OPENCODE_CLIENT` | string | 用戶端識別碼(預設為 `cli`) | +| `OPENCODE_ENABLE_EXA` | boolean | 啟用 Exa 網路搜尋工具 | +| `OPENCODE_SERVER_PASSWORD` | string | 為 `serve`/`web` 啟用基本認證 | +| `OPENCODE_SERVER_USERNAME` | string | 覆寫基本認證使用者名稱(預設為 `opencode`) | +| `OPENCODE_MODELS_URL` | string | 自訂模型設定擷取 URL | + +--- + +### 實驗性功能 + +這些環境變數用於啟用可能會變更或移除的實驗性功能。 + +| 變數 | 類型 | 說明 | +| ----------------------------------------------- | ------- | ------------------------------- | +| `OPENCODE_EXPERIMENTAL` | boolean | 啟用受總開關控制的實驗性功能 | +| `OPENCODE_EXPERIMENTAL_ICON_DISCOVERY` | boolean | 啟用圖示探索 | +| `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT` | boolean | 停用 TUI 中的選取即複製 | +| `OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` | number | bash 指令的預設逾時時間(毫秒) | +| `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | number | LLM 回應的最大輸出 Token 數 | +| `OPENCODE_EXPERIMENTAL_FILEWATCHER` | boolean | 啟用整個目錄的檔案監看器 | +| `OPENCODE_EXPERIMENTAL_OXFMT` | boolean | 啟用 oxfmt 格式化器 | +| `OPENCODE_EXPERIMENTAL_LSP_TOOL` | boolean | 啟用實驗性 LSP 工具 | +| `OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER` | boolean | 停用檔案監看器 | +| `OPENCODE_EXPERIMENTAL_EXA` | boolean | 啟用實驗性 Exa 功能 | +| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | 為 python 檔案啟用 TY LSP | +| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | 啟用計畫模式 | +| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | 啟用背景子代理任務 | +| `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | 啟用實驗性事件系統 | +| `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | 啟用原生 LLM 請求路徑 | +| `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | 啟用平行 Web 搜尋執行 | +| `OPENCODE_EXPERIMENTAL_SCOUT` | boolean | 啟用 Scout 子代理 | +| `OPENCODE_EXPERIMENTAL_WORKSPACES` | boolean | 啟用工作區支援 | diff --git a/packages/web/src/content/docs/zh-tw/config.mdx b/packages/web/src/content/docs/zh-tw/config.mdx new file mode 100644 index 0000000000000000000000000000000000000000..6a670f2cf8517917a1c35552c937b18af4099c9f --- /dev/null +++ b/packages/web/src/content/docs/zh-tw/config.mdx @@ -0,0 +1,687 @@ +--- +title: 設定 +description: 使用 OpenCode JSON 設定。 +--- + +您可以使用 JSON 設定檔來設定 OpenCode。 + +--- + +## 格式 + +OpenCode 支援 **JSON** 和 **JSONC**(帶註解的 JSON)格式。 + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-5", + "autoupdate": true, + "server": { + "port": 4096, + }, +} +``` + +--- + +## 位置 + +您可以將設定放置在不同的位置,它們具有不同的優先順序。 + +:::note +設定檔是**合併在一起**的,而不是替換。 +::: + +設定檔是合併在一起的,而不是被替換。來自以下設定位置的設定會被合併。後面的設定僅在鍵衝突時覆寫前面的設定。所有設定中的非衝突設定都會被保留。 + +例如,如果您的全域設定設定了 `autoupdate: true`,而您的專案設定設定了 `model: "anthropic/claude-sonnet-4-5"`,則最終設定將包含這兩個設定。 + +--- + +### 優先順序 + +設定來源按以下順序載入(後面的來源覆寫前面的來源): + +1. **遠端設定**(來自 `.well-known/opencode`)- 組織預設值 +2. **全域設定**(`~/.config/opencode/opencode.json`)- 使用者偏好 +3. **自訂設定**(`OPENCODE_CONFIG` 環境變數)- 自訂覆寫 +4. **專案設定**(專案中的 `opencode.json`)- 專案特定設定 +5. **`.opencode` 目錄** - 代理、指令、外掛程式 +6. **內嵌設定**(`OPENCODE_CONFIG_CONTENT` 環境變數)- 執行時覆寫 + +這意味著專案設定可以覆寫全域預設值,全域設定可以覆寫遠端組織預設值。 + +:::note +`.opencode` 和 `~/.config/opencode` 目錄的子目錄使用**複數名稱**:`agents/`、`commands/`、`modes/`、`plugins/`、`skills/`、`tools/` 和 `themes/`。為了向後相容,也支援單數名稱(例如 `agent/`)。 +::: + +--- + +### 遠端 + +組織可以透過 `.well-known/opencode` 端點提供預設設定。當您使用支援該功能的供應商進行身分驗證時,會自動擷取此設定。 + +遠端設定最先載入,作為基礎層。所有其他設定來源(全域、專案)都可以覆寫這些預設值。 + +例如,如果您的組織提供了預設停用的 MCP 伺服器: + +```json title="Remote config from .well-known/opencode" +{ + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": false + } + } +} +``` + +您可以在本地設定中啟用特定伺服器: + +```json title="opencode.json" +{ + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } + } +} +``` + +--- + +### 全域 + +將全域 OpenCode 設定放在 `~/.config/opencode/opencode.json` 中。使用全域設定來設定使用者層級的偏好,例如主題、供應商或快捷鍵。 + +全域設定覆寫遠端組織預設值。 + +--- + +### 專案層級 + +在專案根目錄中新增 `opencode.json`。專案設定在標準設定檔中具有最高優先級——它會覆寫全域設定和遠端設定。 + +:::tip +將專案特定設定放在專案的根目錄中。 +::: + +當 OpenCode 啟動時,它會在當前目錄中尋找設定檔,或向上遍歷到最近的 Git 目錄。 + +該設定檔也可以安全地提交到 Git 中,並使用與全域設定相同的 Schema。 + +--- + +### 自訂路徑 + +使用 `OPENCODE_CONFIG` 環境變數指定自訂設定檔路徑。 + +```bash +export OPENCODE_CONFIG=/path/to/my/custom-config.json +opencode run "Hello world" +``` + +自訂設定在優先順序中位於全域設定和專案設定之間載入。 + +--- + +### 自訂目錄 + +使用 `OPENCODE_CONFIG_DIR` 環境變數指定自訂設定目錄。該目錄會像標準 `.opencode` 目錄一樣被搜尋代理、指令、模式和外掛程式,並且應遵循相同的結構。 + +```bash +export OPENCODE_CONFIG_DIR=/path/to/my/config-directory +opencode run "Hello world" +``` + +自訂目錄在全域設定和 `.opencode` 目錄之後載入,因此**可以覆寫**它們的設定。 + +--- + +## Schema + +設定檔具有在 [**`opencode.ai/config.json`**](https://opencode.ai/config.json) 中定義的 Schema。 + +您的編輯器應該能夠基於該 Schema 進行驗證和自動補全。 + +--- + +### TUI + +您可以透過 `tui` 選項設定 TUI 相關設定。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "tui": { + "scroll_speed": 3, + "scroll_acceleration": { + "enabled": true + }, + "diff_style": "auto" + } +} +``` + +可用選項: + +- `scroll_acceleration.enabled` - 啟用 macOS 風格的捲動加速。**優先於 `scroll_speed`。** +- `scroll_speed` - 自訂捲動速度倍率(預設值:`3`,最小值:`1`)。如果 `scroll_acceleration.enabled` 為 `true`,則忽略此選項。 +- `diff_style` - 控制差異呈現方式。`"auto"` 根據終端機寬度自適應,`"stacked"` 始終顯示單列。 + +使用 `OPENCODE_TUI_CONFIG` 指向自訂 TUI 設定檔。 + +`opencode.json` 中的舊版 `theme`、`keybinds` 和 `tui` 鍵已被棄用,並將在可能的情況下自動遷移。 + +[在此了解更多關於 TUI 的資訊](/docs/tui)。 + +--- + +### 伺服器 + +您可以透過 `server` 選項為 `opencode serve` 和 `opencode web` 指令設定伺服器設定。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true, + "mdnsDomain": "myproject.local", + "cors": ["http://localhost:5173"] + } +} +``` + +可用選項: + +- `port` - 監聽連接埠。 +- `hostname` - 監聽主機名稱。當 `mdns` 啟用且未設定主機名稱時,預設為 `0.0.0.0`。 +- `mdns` - 啟用 mDNS 服務探索。這允許網路上的其他裝置發現您的 OpenCode 伺服器。 +- `mdnsDomain` - mDNS 服務的自訂網域名稱。預設為 `opencode.local`。適用於在同一網路上執行多個實例的情境。 +- `cors` - 從基於瀏覽器的用戶端使用 HTTP 伺服器時允許 CORS 的額外來源。值必須是完整的來源(通訊協定 + 主機 + 可選連接埠),例如 `https://app.example.com`。 + +[在此了解更多關於伺服器的資訊](/docs/server)。 + +--- + +### 工具 + +您可以透過 `tools` 選項管理 LLM 可以使用的工具。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "tools": { + "write": false, + "bash": false + } +} +``` + +[在此了解更多關於工具的資訊](/docs/tools)。 + +--- + +### 模型 + +您可以透過 `provider`、`model` 和 `small_model` 選項在 OpenCode 設定中設定要使用的供應商和模型。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": {}, + "model": "anthropic/claude-sonnet-4-5", + "small_model": "anthropic/claude-haiku-4-5" +} +``` + +`small_model` 選項為標題生成等輕量級任務設定單獨的模型。預設情況下,如果您的供應商有更便宜的模型可用,OpenCode 會嘗試使用該模型,否則會回退到您的主模型。 + +供應商選項可以包括 `timeout` 和 `setCacheKey`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "timeout": 600000, + "setCacheKey": true + } + } + } +} +``` + +- `timeout` - 請求逾時時間,單位為毫秒(預設值:300000)。設定為 `false` 可停用逾時。 +- `setCacheKey` - 確保始終為指定供應商設定快取金鑰。 + +您還可以設定[本地模型](/docs/models#local)。[了解更多](/docs/models)。 + +--- + +#### 供應商特定選項 + +一些供應商支援除通用 `timeout` 和 `apiKey` 設定之外的額外設定選項。 + +##### Amazon Bedrock + +Amazon Bedrock 支援 AWS 特定設定: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "amazon-bedrock": { + "options": { + "region": "us-east-1", + "profile": "my-aws-profile", + "endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" + } + } + } +} +``` + +- `region` - Bedrock 的 AWS 區域(預設為 `AWS_REGION` 環境變數或 `us-east-1`) +- `profile` - 來自 `~/.aws/credentials` 的 AWS 命名設定檔(預設為 `AWS_PROFILE` 環境變數) +- `endpoint` - VPC 端點的自訂端點 URL。這是通用 `baseURL` 選項使用 AWS 特定術語的別名。如果兩者都指定,`endpoint` 優先。 + +:::note +Bearer Token(`AWS_BEARER_TOKEN_BEDROCK` 或 `/connect`)優先於基於設定檔的身分驗證。詳情請參見[認證優先級](/docs/providers#authentication-precedence)。 +::: + +[了解更多關於 Amazon Bedrock 設定的資訊](/docs/providers#amazon-bedrock)。 + +--- + +### 主題 + +在 `tui.json` 中設定您的 UI 主題。 + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "tokyonight" +} +``` + +[在此了解更多](/docs/themes)。 + +--- + +### 代理 + +您可以透過 `agent` 選項為特定任務設定專用代理。 + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "code-reviewer": { + "description": "Reviews code for best practices and potential issues", + "model": "anthropic/claude-sonnet-4-5", + "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", + "tools": { + // Disable file modification tools for review-only agent + "write": false, + "edit": false, + }, + }, + }, +} +``` + +您還可以使用 `~/.config/opencode/agents/` 或 `.opencode/agents/` 中的 Markdown 檔案定義代理。[在此了解更多](/docs/agents)。 + +--- + +### 預設代理 + +您可以使用 `default_agent` 選項設定預設代理。當未明確指定代理時,將使用該預設代理。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "default_agent": "plan" +} +``` + +預設代理必須是主代理(不能是子代理)。可以是內建代理(如 `"build"` 或 `"plan"`),也可以是您定義的[自訂代理](/docs/agents)。如果指定的代理不存在或是子代理,OpenCode 將回退到 `"build"` 並發出警告。 + +此設定適用於所有介面:TUI、CLI(`opencode run`)、桌面應用程式和 GitHub Action。 + +--- + +### 分享 + +您可以透過 `share` 選項設定[分享](/docs/share)功能。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "manual" +} +``` + +該選項接受: + +- `"manual"` - 允許透過指令手動分享(預設) +- `"auto"` - 自動分享新工作階段 +- `"disabled"` - 完全停用分享 + +預設情況下,分享設定為手動模式,您需要使用 `/share` 指令明確分享工作階段。 + +--- + +### 指令 + +您可以透過 `command` 選項為重複任務設定自訂指令。 + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "command": { + "test": { + "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.", + "description": "Run tests with coverage", + "agent": "build", + "model": "anthropic/claude-haiku-4-5", + }, + "component": { + "template": "Create a new React component named $ARGUMENTS with TypeScript support.\nInclude proper typing and basic structure.", + "description": "Create a new component", + }, + }, +} +``` + +您還可以使用 `~/.config/opencode/commands/` 或 `.opencode/commands/` 中的 Markdown 檔案定義指令。[在此了解更多](/docs/commands)。 + +--- + +### 快捷鍵 + +在 `tui.json` 中自訂快捷鍵。 + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keybinds": {} +} +``` + +[在此了解更多](/docs/keybinds)。 + +--- + +### 自動更新 + +OpenCode 啟動時會自動下載新版本。您可以使用 `autoupdate` 選項停用此功能。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "autoupdate": false +} +``` + +如果您不想自動更新但希望在新版本可用時收到通知,可將 `autoupdate` 設定為 `"notify"`。 +請注意,此功能僅在未透過 Homebrew 等套件管理器安裝時有效。 + +--- + +### 格式化器 + +您可以透過 `formatter` 選項設定程式碼格式化器。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "disabled": true + }, + "custom-prettier": { + "command": ["npx", "prettier", "--write", "$FILE"], + "environment": { + "NODE_ENV": "development" + }, + "extensions": [".js", ".ts", ".jsx", ".tsx"] + } + } +} +``` + +[在此了解更多關於格式化器的資訊](/docs/formatters)。 + +--- + +### 權限 + +預設情況下,OpenCode **允許所有操作**,無需明確批准。您可以使用 `permission` 選項變更此行為。 + +例如,要讓 `edit` 和 `bash` 工具需要使用者確認: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "ask", + "bash": "ask" + } +} +``` + +[在此了解更多關於權限的資訊](/docs/permissions)。 + +--- + +### 壓縮 + +您可以透過 `compaction` 選項控制上下文壓縮行為。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "compaction": { + "auto": true, + "prune": false, + "reserved": 10000 + } +} +``` + +- `auto` - 當上下文已滿時自動壓縮工作階段(預設值:`true`)。 +- `prune` - 刪除舊的工具輸出以節省 Token(預設值:`false`)。 +- `reserved` - 壓縮時的 Token 緩衝區。保留足夠的窗口以避免壓縮過程中溢出。 + +--- + +### 檔案監看器 + +您可以透過 `watcher` 選項設定檔案監看器的忽略模式。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "watcher": { + "ignore": ["node_modules/**", "dist/**", ".git/**"] + } +} +``` + +模式遵循 glob 語法。使用此選項可以從檔案監看中排除頻繁變動的目錄。 + +--- + +### MCP 伺服器 + +您可以透過 `mcp` 選項設定要使用的 MCP 伺服器。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": {} +} +``` + +[在此了解更多](/docs/mcp-servers)。 + +--- + +### 外掛程式 + +[外掛程式](/docs/plugins)透過自訂工具、掛鉤和整合來擴展 OpenCode。 + +將外掛程式檔案放置在 `.opencode/plugins/` 或 `~/.config/opencode/plugins/` 中。您還可以透過 `plugin` 選項從 npm 載入外掛程式。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-helicone-session", "@my-org/custom-plugin"] +} +``` + +[在此了解更多](/docs/plugins)。 + +--- + +### 指示 + +您可以透過 `instructions` 選項為所使用的模型設定指示。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md"] +} +``` + +該選項接受指示檔案路徑和 glob 模式的陣列。[在此了解更多關於規則的資訊](/docs/rules)。 + +--- + +### 停用供應商 + +您可以透過 `disabled_providers` 選項停用自動載入的供應商。當您希望阻止某些供應商被載入(即使其憑證可用)時,此選項非常有用。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "disabled_providers": ["openai", "gemini"] +} +``` + +:::note +`disabled_providers` 優先於 `enabled_providers`。 +::: + +`disabled_providers` 選項接受供應商 ID 的陣列。當某個供應商被停用時: + +- 即使設定了環境變數,也不會被載入。 +- 即使透過 `/connect` 指令設定了 API 金鑰,也不會被載入。 +- 該供應商的模型不會出現在模型選擇列表中。 + +--- + +### 啟用供應商 + +您可以透過 `enabled_providers` 選項指定允許使用的供應商白名單。設定後,僅啟用指定的供應商,所有其他供應商將被忽略。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "enabled_providers": ["anthropic", "openai"] +} +``` + +當您希望限制 OpenCode 僅使用特定供應商,而不是逐一停用其他供應商時,此選項非常有用。 + +:::note +`disabled_providers` 優先於 `enabled_providers`。 +::: + +如果某個供應商同時出現在 `enabled_providers` 和 `disabled_providers` 中,為了向後相容,`disabled_providers` 優先。 + +--- + +### 實驗性功能 + +`experimental` 鍵包含正在積極開發中的選項。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": {} +} +``` + +:::caution +實驗性選項不穩定。它們可能會在不另行通知的情況下被變更或移除。 +::: + +--- + +## 變數 + +您可以在設定檔中使用變數替換來參照環境變數和檔案內容。 + +--- + +### 環境變數 + +使用 `{env:VARIABLE_NAME}` 來替換環境變數: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "{env:OPENCODE_MODEL}", + "provider": { + "anthropic": { + "models": {}, + "options": { + "apiKey": "{env:ANTHROPIC_API_KEY}" + } + } + } +} +``` + +如果環境變數未設定,它將被替換為空字串。 + +--- + +### 檔案 + +使用 `{file:path/to/file}` 來替換檔案內容: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["./custom-instructions.md"], + "provider": { + "openai": { + "options": { + "apiKey": "{file:~/.secrets/openai-key}" + } + } + } +} +``` + +檔案路徑可以是: + +- 相對於設定檔所在目錄的路徑 +- 以 `/` 或 `~` 開頭的絕對路徑 + +這些功能適用於: + +- 將 API 金鑰等敏感資料保存在單獨的檔案中。 +- 引入大型指示檔案而不會使設定變得雜亂。 +- 在多個設定檔之間共享通用設定片段。 diff --git a/packages/web/src/content/docs/zh-tw/formatters.mdx b/packages/web/src/content/docs/zh-tw/formatters.mdx new file mode 100644 index 0000000000000000000000000000000000000000..bb462a66d3e4fd8649e6077f923f098ff8f1d889 --- /dev/null +++ b/packages/web/src/content/docs/zh-tw/formatters.mdx @@ -0,0 +1,132 @@ +--- +title: 格式化器 +description: OpenCode 使用特定語言的格式化器。 +--- + +OpenCode 會在檔案寫入或編輯後,自動使用特定語言的格式化器對其進行格式化。這確保了生成的程式碼遵循您專案的程式碼風格。 + +--- + +## 內建格式化器 + +OpenCode 內建了多種適用於主流語言和框架的格式化器。下表列出了各格式化器、支援的副檔名以及所需的指令或設定選項。 + +| 格式化器 | 副檔名 | 要求 | +| -------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| air | .R | `air` 指令可用 | +| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml 及[更多](https://biomejs.dev/) | `biome.json(c)` 設定檔 | +| cargofmt | .rs | `cargo fmt` 指令可用 | +| clang-format | .c, .cpp, .h, .hpp, .ino 及[更多](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` 設定檔 | +| cljfmt | .clj, .cljs, .cljc, .edn | `cljfmt` 指令可用 | +| dart | .dart | `dart` 指令可用 | +| dfmt | .d | `dfmt` 指令可用 | +| gleam | .gleam | `gleam` 指令可用 | +| gofmt | .go | `gofmt` 指令可用 | +| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` 指令可用 | +| ktlint | .kt, .kts | `ktlint` 指令可用 | +| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` 指令可用 | +| nixfmt | .nix | `nixfmt` 指令可用 | +| ocamlformat | .ml, .mli | `ocamlformat` 指令可用且存在 `.ocamlformat` 設定檔 | +| ormolu | .hs | `ormolu` 指令可用 | +| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `package.json` 中有 `oxfmt` 相依套件,且設定了[實驗性環境變數旗標](/docs/cli/#experimental) | +| pint | .php | `composer.json` 中有 `laravel/pint` 相依套件 | +| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml 及[更多](https://prettier.io/docs/en/index.html) | `package.json` 中有 `prettier` 相依套件 | +| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` 指令可用 | +| ruff | .py, .pyi | `ruff` 指令可用且有相應設定 | +| rustfmt | .rs | `rustfmt` 指令可用 | +| shfmt | .sh, .bash | `shfmt` 指令可用 | +| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` 指令可用 | +| terraform | .tf, .tfvars | `terraform` 指令可用 | +| uv | .py, .pyi | `uv` 指令可用 | +| zig | .zig, .zon | `zig` 指令可用 | + +因此,如果您的專案 `package.json` 中包含 `prettier`,OpenCode 會自動使用它進行格式化。 + +--- + +## 工作原理 + +當 OpenCode 寫入或編輯檔案時,它會: + +1. 根據所有已啟用的格式化器檢查副檔名。 +2. 對檔案執行相應的格式化指令。 +3. 自動套用格式化變更。 + +整個過程在背景完成,無需任何手動操作即可保持程式碼風格的一致性。 + +--- + +## 設定 + +您可以透過 OpenCode 設定中的 `formatter` 部分自訂格式化器。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": {} +} +``` + +每個格式化器的設定支援以下屬性: + +| 屬性 | 型別 | 說明 | +| ------------- | -------- | ---------------------------- | +| `disabled` | boolean | 設為 `true` 可停用該格式化器 | +| `command` | string[] | 執行格式化的指令 | +| `environment` | object | 執行格式化器時設定的環境變數 | +| `extensions` | string[] | 該格式化器處理的副檔名 | + +下面來看一些範例。 + +--- + +### 停用格式化器 + +要全域停用**所有**格式化器,將 `formatter` 設為 `false`: + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": false +} +``` + +要停用**特定**格式化器,將 `disabled` 設為 `true`: + +```json title="opencode.json" {5} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "disabled": true + } + } +} +``` + +--- + +### 自訂格式化器 + +您可以透過指定指令、環境變數和副檔名來覆寫內建格式化器或新增新的格式化器: + +```json title="opencode.json" {4-14} +{ + "$schema": "https://opencode.ai/config.json", + "formatter": { + "prettier": { + "command": ["npx", "prettier", "--write", "$FILE"], + "environment": { + "NODE_ENV": "development" + }, + "extensions": [".js", ".ts", ".jsx", ".tsx"] + }, + "custom-markdown-formatter": { + "command": ["deno", "fmt", "$FILE"], + "extensions": [".md"] + } + } +} +``` + +指令中的 **`$FILE` 佔位符**會被替換為待格式化檔案的路徑。 diff --git a/packages/web/src/content/docs/zh-tw/lsp.mdx b/packages/web/src/content/docs/zh-tw/lsp.mdx new file mode 100644 index 0000000000000000000000000000000000000000..32e1f05079fdc513427089b540991e2e0a58c5c7 --- /dev/null +++ b/packages/web/src/content/docs/zh-tw/lsp.mdx @@ -0,0 +1,208 @@ +--- +title: LSP 伺服器 +description: OpenCode 與您的 LSP 伺服器整合。 +--- + +OpenCode 可以與語言伺服器協定(LSP)伺服器整合,將診斷資訊作為 agent 的回饋。 + +--- + +## 內建支援 + +OpenCode 內建了多種適用於主流語言的 LSP 伺服器: + +| LSP 伺服器 | 副檔名 | 要求 | +| ------------------ | ------------------------------------------------------------------- | ----------------------------------------------------- | +| astro | .astro | 為 Astro 專案自動安裝 | +| bash | .sh, .bash, .zsh, .ksh | 自動安裝 bash-language-server | +| clangd | .c, .cpp, .cc, .cxx, .c++, .h, .hpp, .hh, .hxx, .h++ | 為 C/C++ 專案自動安裝 | +| csharp | .cs | 需要已安裝 `.NET SDK` | +| clojure-lsp | .clj, .cljs, .cljc, .edn | 需要 `clojure-lsp` 指令可用 | +| dart | .dart | 需要 `dart` 指令可用 | +| deno | .ts, .tsx, .js, .jsx, .mjs | 需要 `deno` 指令可用(自動偵測 deno.json/deno.jsonc) | +| elixir-ls | .ex, .exs | 需要 `elixir` 指令可用 | +| eslint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue | 專案中需要 `eslint` 相依套件 | +| fsharp | .fs, .fsi, .fsx, .fsscript | 需要已安裝 `.NET SDK` | +| gleam | .gleam | 需要 `gleam` 指令可用 | +| gopls | .go | 需要 `go` 指令可用 | +| hls | .hs, .lhs | 需要 `haskell-language-server-wrapper` 指令可用 | +| jdtls | .java | 需要已安裝 `Java SDK (version 21+)` | +| julials | .jl | 需要已安裝 `julia` 和 `LanguageServer.jl` | +| kotlin-ls | .kt, .kts | 為 Kotlin 專案自動安裝 | +| lua-ls | .lua | 為 Lua 專案自動安裝 | +| nixd | .nix | 需要 `nixd` 指令可用 | +| ocaml-lsp | .ml, .mli | 需要 `ocamllsp` 指令可用 | +| oxlint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue, .astro, .svelte | 專案中需要 `oxlint` 相依套件 | +| php intelephense | .php | 為 PHP 專案自動安裝 | +| prisma | .prisma | 需要 `prisma` 指令可用 | +| pyright | .py, .pyi | 需要已安裝 `pyright` 相依套件 | +| ruby-lsp (rubocop) | .rb, .rake, .gemspec, .ru | 需要 `ruby` 和 `gem` 指令可用 | +| rust | .rs | 需要 `rust-analyzer` 指令可用 | +| sourcekit-lsp | .swift, .objc, .objcpp | 需要已安裝 `swift`(macOS 上為 `xcode`) | +| svelte | .svelte | 為 Svelte 專案自動安裝 | +| terraform | .tf, .tfvars | 從 GitHub releases 自動安裝 | +| tinymist | .typ, .typc | 從 GitHub releases 自動安裝 | +| typescript | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts | 專案中需要 `typescript` 相依套件 | +| vue | .vue | 為 Vue 專案自動安裝 | +| yaml-ls | .yaml, .yml | 自動安裝 Red Hat yaml-language-server | +| zls | .zig, .zon | 需要 `zig` 指令可用 | + +LSP 預設關閉。啟用後,當偵測到上述檔案副檔名且滿足相應要求時,伺服器會啟動。 + +:::note +您可以將 `OPENCODE_DISABLE_LSP_DOWNLOAD` 環境變數設定為 `true` 來停用 LSP 伺服器的自動下載。 +::: + +--- + +## 工作原理 + +啟用 LSP 且 OpenCode 開啟檔案時,它會: + +1. 將檔案副檔名與所有已啟用的 LSP 伺服器進行比對。 +2. 如果對應的 LSP 伺服器尚未執行,則自動啟動它。 + +--- + +## 最佳實踐 + +LSP 可以透過語言伺服器診斷幫助 agent 發現並修復問題。這對某些專案很有用,但不一定總是帶來淨收益。 + +語言伺服器可能與專案不同步、佔用較多記憶體、隨版本或專案表現不同,並拖慢 agent 工作流程。在許多專案中,更好的做法是讓 agent 直接執行 lint、typecheck 或其他診斷類 CLI 工具,這樣錯誤會回到 agent 循環,同時避免這些取捨。將這些指令記錄在 `AGENTS.md` 或 skills 等指示檔中,讓 agent 知道該執行什麼。當您的專案能從額外的語言伺服器回饋中受益時再啟用 LSP。 + +--- + +## 設定 + +您可以透過 OpenCode 設定檔中的 `lsp` 部分來啟用並自訂 LSP 伺服器。 + +要啟用所有內建 LSP 伺服器,請將 `lsp` 設定為 `true`。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +使用物件可以在保持內建伺服器啟用的同時設定覆寫項或自訂伺服器。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": {} +} +``` + +每個 LSP 伺服器支援以下設定項: + +| 屬性 | 類型 | 描述 | +| ---------------- | -------- | --------------------------------- | +| `disabled` | boolean | 設定為 `true` 可停用該 LSP 伺服器 | +| `command` | string[] | 啟動 LSP 伺服器的指令 | +| `extensions` | string[] | 該 LSP 伺服器需要處理的檔案副檔名 | +| `env` | object | 啟動伺服器時設定的環境變數 | +| `initialization` | object | 傳送給 LSP 伺服器的初始化選項 | + +下面來看一些範例。 + +--- + +### 環境變數 + +使用 `env` 屬性在啟動 LSP 伺服器時設定環境變數: + +```json title="opencode.json" {5-7} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "rust": { + "env": { + "RUST_LOG": "debug" + } + } + } +} +``` + +--- + +### 初始化選項 + +使用 `initialization` 屬性向 LSP 伺服器傳遞初始化選項。這些是在 LSP `initialize` 請求期間傳送的伺服器特定設定: + +```json title="opencode.json" {5-9} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "initialization": { + "preferences": { + "importModuleSpecifierPreference": "relative" + } + } + } + } +} +``` + +:::note +初始化選項因 LSP 伺服器而異。請查閱您所使用的 LSP 伺服器的文件以了解可用選項。 +::: + +--- + +### 停用 LSP 伺服器 + +如果省略 `lsp`,所有 LSP 伺服器都會被停用。如果另一個設定啟用了 LSP,可將 `lsp` 設定為 `false` 來停用所有 LSP 伺服器: + +```json title="opencode.json" {3} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": false +} +``` + +要停用**特定的** LSP 伺服器,將 `disabled` 設定為 `true`: + +```json title="opencode.json" {5} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "disabled": true + } + } +} +``` + +--- + +### 自訂 LSP 伺服器 + +您可以透過指定指令和檔案副檔名來新增自訂 LSP 伺服器: + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "custom-lsp": { + "command": ["custom-lsp-server", "--stdio"], + "extensions": [".custom"] + } + } +} +``` + +--- + +## 補充資訊 + +### PHP Intelephense + +PHP Intelephense 透過授權金鑰提供進階功能。您可以將授權金鑰單獨放在以下路徑的文字檔案中: + +- macOS/Linux:`$HOME/intelephense/license.txt` +- Windows:`%USERPROFILE%/intelephense/license.txt` + +該檔案應僅包含授權金鑰,不要新增其他任何內容。 diff --git a/packages/web/src/content/docs/zh-tw/sdk.mdx b/packages/web/src/content/docs/zh-tw/sdk.mdx new file mode 100644 index 0000000000000000000000000000000000000000..2ed216d8b91a4d3bc980d15e86e86e33a9145a72 --- /dev/null +++ b/packages/web/src/content/docs/zh-tw/sdk.mdx @@ -0,0 +1,463 @@ +--- +title: SDK +description: opencode 伺服器的型別安全 JS 用戶端。 +--- + +import config from "../../../../config.mjs" +export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` + +opencode JS/TS SDK 提供了一個型別安全的用戶端,用於與伺服器進行互動。 +您可以用它來建構整合方案,並以程式化方式控制 opencode。 + +[了解更多](/docs/server)關於伺服器的運作原理。如需範例,請查看社群建構的[專案](/docs/ecosystem#projects)。 + +--- + +## 安裝 + +從 npm 安裝 SDK: + +```bash +npm install @opencode-ai/sdk +``` + +--- + +## 建立用戶端 + +建立一個 opencode 實例: + +```javascript +import { createOpencode } from "@opencode-ai/sdk" + +const { client } = await createOpencode() +``` + +這會同時啟動伺服器和用戶端。 + +#### 選項 + +| 選項 | 型別 | 描述 | 預設值 | +| ---------- | ------------- | -------------------------- | ----------- | +| `hostname` | `string` | 伺服器主機名稱 | `127.0.0.1` | +| `port` | `number` | 伺服器連接埠 | `4096` | +| `signal` | `AbortSignal` | 用於取消操作的中止訊號 | `undefined` | +| `timeout` | `number` | 伺服器啟動逾時時間(毫秒) | `5000` | +| `config` | `Config` | 設定物件 | `{}` | + +--- + +## 設定 + +您可以傳入一個設定物件來自訂行為。實例仍然會讀取您的 `opencode.json`,但您可以透過內嵌方式覆寫或新增設定: + +```javascript +import { createOpencode } from "@opencode-ai/sdk" + +const opencode = await createOpencode({ + hostname: "127.0.0.1", + port: 4096, + config: { + model: "anthropic/claude-3-5-sonnet-20241022", + }, +}) + +console.log(`Server running at ${opencode.server.url}`) + +opencode.server.close() +``` + +## 僅用戶端模式 + +如果您已經有一個正在執行的 opencode 實例,可以建立一個用戶端實例來連線: + +```javascript +import { createOpencodeClient } from "@opencode-ai/sdk" + +const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", +}) +``` + +#### 選項 + +| 選項 | 型別 | 描述 | 預設值 | +| --------------- | ---------- | ---------------------------- | ----------------------- | +| `baseUrl` | `string` | 伺服器 URL | `http://localhost:4096` | +| `fetch` | `function` | 自訂 fetch 實作 | `globalThis.fetch` | +| `parseAs` | `string` | 回應解析方式 | `auto` | +| `responseStyle` | `string` | 回傳風格:`data` 或 `fields` | `fields` | +| `throwOnError` | `boolean` | 拋出錯誤而非回傳錯誤 | `false` | + +--- + +## 型別 + +SDK 包含所有 API 型別的 TypeScript 定義。您可以直接匯入它們: + +```typescript +import type { Session, Message, Part } from "@opencode-ai/sdk" +``` + +所有型別均根據伺服器的 OpenAPI 規範產生,可在型別檔案中查看。 + +--- + +## 錯誤處理 + +SDK 可能會拋出錯誤,您可以捕捉並處理這些錯誤: + +```typescript +try { + await client.session.get({ path: { id: "invalid-id" } }) +} catch (error) { + console.error("Failed to get session:", (error as Error).message) +} +``` + +--- + +## 結構化輸出 + +您可以透過指定帶有 JSON Schema 的 `format` 來請求模型回傳結構化的 JSON 輸出。模型會使用 `StructuredOutput` 工具回傳符合您 Schema 的經過驗證的 JSON。 + +### 基本用法 + +```typescript +const result = await client.session.prompt({ + path: { id: sessionId }, + body: { + parts: [{ type: "text", text: "Research Anthropic and provide company info" }], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + company: { type: "string", description: "Company name" }, + founded: { type: "number", description: "Year founded" }, + products: { + type: "array", + items: { type: "string" }, + description: "Main products", + }, + }, + required: ["company", "founded"], + }, + }, + }, +}) + +// Access the structured output +console.log(result.data.info.structured_output) +// { company: "Anthropic", founded: 2021, products: ["Claude", "Claude API"] } +``` + +### 輸出格式型別 + +| 型別 | 描述 | +| ------------- | --------------------------------------- | +| `text` | 預設值。標準文字回應(無結構化輸出) | +| `json_schema` | 回傳符合所提供 Schema 的經過驗證的 JSON | + +### JSON Schema 格式 + +使用 `type: 'json_schema'` 時,需提供以下欄位: + +| 欄位 | 型別 | 描述 | +| ------------ | --------------- | ------------------------------------- | +| `type` | `'json_schema'` | 必填。指定 JSON Schema 模式 | +| `schema` | `object` | 必填。定義輸出結構的 JSON Schema 物件 | +| `retryCount` | `number` | 選填。驗證重試次數(預設值:2) | + +### 錯誤處理 + +如果模型在所有重試後仍無法產生有效的結構化輸出,回應中會包含 `StructuredOutputError`: + +```typescript +if (result.data.info.error?.name === "StructuredOutputError") { + console.error("Failed to produce structured output:", result.data.info.error.message) + console.error("Attempts:", result.data.info.error.retries) +} +``` + +### 最佳實務 + +1. **在 Schema 屬性中提供清晰的描述**,幫助模型理解需要擷取的資料 +2. **使用 `required`** 指定哪些欄位必須存在 +3. **保持 Schema 簡潔** — 複雜的巢狀 Schema 可能會讓模型更難正確填充 +4. **設定合適的 `retryCount`** — 對於複雜 Schema 可增加重試次數,對於簡單 Schema 可減少 + +--- + +## API + +SDK 透過型別安全的用戶端公開所有伺服器 API。 + +--- + +### Global + +| 方法 | 描述 | 回應 | +| ----------------- | ------------------------ | ------------------------------------ | +| `global.health()` | 檢查伺服器健康狀態和版本 | `{ healthy: true, version: string }` | + +--- + +#### 範例 + +```javascript +const health = await client.global.health() +console.log(health.data.version) +``` + +--- + +### App + +| 方法 | 描述 | 回應 | +| -------------- | ------------------ | ------------------------------------------- | +| `app.log()` | 寫入一筆日誌 | `boolean` | +| `app.agents()` | 列出所有可用的代理 | Agent[] | + +--- + +#### 範例 + +```javascript +// 寫入一筆日誌 +await client.app.log({ + body: { + service: "my-app", + level: "info", + message: "Operation completed", + }, +}) + +// 列出可用的代理 +const agents = await client.app.agents() +``` + +--- + +### Project + +| 方法 | 描述 | 回應 | +| ------------------- | ------------ | --------------------------------------------- | +| `project.list()` | 列出所有專案 | Project[] | +| `project.current()` | 取得當前專案 | Project | + +--- + +#### 範例 + +```javascript +// List all projects +const projects = await client.project.list() + +// Get current project +const currentProject = await client.project.current() +``` + +--- + +### Path + +| 方法 | 描述 | 回應 | +| ------------ | ------------ | ---------------------------------------- | +| `path.get()` | 取得當前路徑 | Path | + +--- + +#### 範例 + +```javascript +// 取得當前路徑資訊 +const pathInfo = await client.path.get() +``` + +--- + +### Config + +| 方法 | 描述 | 回應 | +| -------------------- | -------------------- | ----------------------------------------------------------------------------------------------------- | +| `config.get()` | 取得設定資訊 | Config | +| `config.providers()` | 列出供應商和預設模型 | `{ providers: `Provider[]`, default: { [key: string]: string } }` | + +--- + +#### 範例 + +```javascript +const config = await client.config.get() + +const { providers, default: defaults } = await client.config.providers() +``` + +--- + +### Sessions + +| 方法 | 描述 | 備註 | +| ---------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session.list()` | 列出工作階段 | 回傳 Session[] | +| `session.get({ path })` | 取得工作階段 | 回傳 Session | +| `session.children({ path })` | 列出子工作階段 | 回傳 Session[] | +| `session.create({ body })` | 建立工作階段 | 回傳 Session | +| `session.delete({ path })` | 刪除工作階段 | 回傳 `boolean` | +| `session.update({ path, body })` | 更新工作階段屬性 | 回傳 Session | +| `session.init({ path, body })` | 分析應用程式並建立 `AGENTS.md` | 回傳 `boolean` | +| `session.abort({ path })` | 中止正在執行的工作階段 | 回傳 `boolean` | +| `session.share({ path })` | 分享工作階段 | 回傳 Session | +| `session.unshare({ path })` | 取消分享工作階段 | 回傳 Session | +| `session.summarize({ path, body })` | 摘要工作階段 | 回傳 `boolean` | +| `session.messages({ path })` | 列出工作階段中的訊息 | 回傳 `{ info: `Message`, parts: `Part[]`}[]` | +| `session.message({ path })` | 取得訊息詳情 | 回傳 `{ info: `Message`, parts: `Part[]`}` | +| `session.prompt({ path, body })` | 傳送提示訊息 | `body.noReply: true` 回傳 UserMessage(僅注入上下文)。預設回傳帶有 AI 回應的 AssistantMessage。支援透過 `body.outputFormat` 使用[結構化輸出](#結構化輸出) | +| `session.command({ path, body })` | 向工作階段傳送指令 | 回傳 `{ info: `AssistantMessage`, parts: `Part[]`}` | +| `session.shell({ path, body })` | 執行 shell 指令 | 回傳 AssistantMessage | +| `session.revert({ path, body })` | 還原訊息 | 回傳 Session | +| `session.unrevert({ path })` | 恢復已還原的訊息 | 回傳 Session | +| `postSessionByIdPermissionsByPermissionId({ path, body })` | 回覆權限請求 | 回傳 `boolean` | + +--- + +#### 範例 + +```javascript +// Create and manage sessions +const session = await client.session.create({ + body: { title: "My session" }, +}) + +const sessions = await client.session.list() + +// Send a prompt message +const result = await client.session.prompt({ + path: { id: session.id }, + body: { + model: { providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" }, + parts: [{ type: "text", text: "Hello!" }], + }, +}) + +// Inject context without triggering AI response (useful for plugins) +await client.session.prompt({ + path: { id: session.id }, + body: { + noReply: true, + parts: [{ type: "text", text: "You are a helpful assistant." }], + }, +}) +``` + +--- + +### Files + +| 方法 | 描述 | 回應 | +| ------------------------- | -------------------- | ----------------------------------------------------------------------------------- | +| `find.text({ query })` | 搜尋檔案中的文字 | 包含 `path`、`lines`、`line_number`、`absolute_offset`、`submatches` 的比對物件陣列 | +| `find.files({ query })` | 按名稱尋找檔案和目錄 | `string[]`(路徑) | +| `find.symbols({ query })` | 尋找工作區符號 | Symbol[] | +| `file.read({ query })` | 讀取檔案 | `{ type: "raw" \| "patch", content: string }` | +| `file.status({ query? })` | 取得已追蹤檔案的狀態 | File[] | + +`find.files` 支援以下選填的查詢欄位: + +- `type`:`"file"` 或 `"directory"` +- `directory`:覆寫搜尋的專案根目錄 +- `limit`:最大結果數(1–200) + +--- + +#### 範例 + +```javascript +// 搜尋和讀取檔案 +const textResults = await client.find.text({ + query: { pattern: "function.*opencode" }, +}) + +const files = await client.find.files({ + query: { query: "*.ts", type: "file" }, +}) + +const directories = await client.find.files({ + query: { query: "packages", type: "directory", limit: 20 }, +}) + +const content = await client.file.read({ + query: { path: "src/index.ts" }, +}) +``` + +--- + +### TUI + +| 方法 | 描述 | 回應 | +| ------------------------------ | ------------------ | --------- | +| `tui.appendPrompt({ body })` | 向提示詞追加文字 | `boolean` | +| `tui.openHelp()` | 開啟說明對話框 | `boolean` | +| `tui.openSessions()` | 開啟工作階段選擇器 | `boolean` | +| `tui.openThemes()` | 開啟主題選擇器 | `boolean` | +| `tui.openModels()` | 開啟模型選擇器 | `boolean` | +| `tui.submitPrompt()` | 送出當前提示詞 | `boolean` | +| `tui.clearPrompt()` | 清除提示詞 | `boolean` | +| `tui.executeCommand({ body })` | 執行指令 | `boolean` | +| `tui.showToast({ body })` | 顯示 Toast 通知 | `boolean` | + +--- + +#### 範例 + +```javascript +// 控制 TUI 介面 +await client.tui.appendPrompt({ + body: { text: "Add this to prompt" }, +}) + +await client.tui.showToast({ + body: { message: "Task completed", variant: "success" }, +}) +``` + +--- + +### Auth + +| 方法 | 描述 | 回應 | +| ------------------- | ------------ | --------- | +| `auth.set({ ... })` | 設定驗證憑證 | `boolean` | + +--- + +#### 範例 + +```javascript +await client.auth.set({ + path: { id: "anthropic" }, + body: { type: "api", key: "your-api-key" }, +}) +``` + +--- + +### Events + +| 方法 | 描述 | 回應 | +| ------------------- | -------------------- | -------------------- | +| `event.subscribe()` | 伺服器傳送的事件串流 | 伺服器傳送的事件串流 | + +--- + +#### 範例 + +```javascript +// Listen to real-time events +const events = await client.event.subscribe() +for await (const event of events.stream) { + console.log("Event:", event.type, event.properties) +} +``` diff --git a/packages/web/src/content/docs/zh-tw/tools.mdx b/packages/web/src/content/docs/zh-tw/tools.mdx new file mode 100644 index 0000000000000000000000000000000000000000..763d0ce9a7a97cc2beb0bf6ba7aeeabb78254910 --- /dev/null +++ b/packages/web/src/content/docs/zh-tw/tools.mdx @@ -0,0 +1,341 @@ +--- +title: 工具 +description: 管理 LLM 可以使用的工具。 +--- + +工具允許 LLM 在您的程式碼庫中執行操作。OpenCode 自帶一組內建工具,您也可以透過[自訂工具](/docs/custom-tools)或 [MCP 伺服器](/docs/mcp-servers)來擴充它。 + +預設情況下,所有工具都是**啟用**的,且無需權限即可執行。您可以透過[權限](/docs/permissions)來控制工具的行為。 + +--- + +## 設定 + +使用 `permission` 欄位來控制工具行為。您可以對每個工具設定允許、拒絕或需要審批。 + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny", + "bash": "ask", + "webfetch": "allow" + } +} +``` + +您還可以使用萬用字元同時控制多個工具。例如,要求某個 MCP 伺服器的所有工具都需要審批: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "mymcp_*": "ask" + } +} +``` + +[了解更多](/docs/permissions)關於設定權限的內容。 + +--- + +## 內建工具 + +以下是 OpenCode 中所有可用的內建工具。 + +--- + +### bash + +在專案環境中執行 shell 指令。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": "allow" + } +} +``` + +該工具允許 LLM 執行終端機指令,例如 `npm install`、`git status` 或其他任何 shell 指令。 + +--- + +### edit + +透過精確的字串替換來修改現有檔案。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +該工具透過替換精確匹配的文字來對檔案進行編輯。這是 LLM 修改程式碼的主要方式。 + +--- + +### write + +建立新檔案或覆蓋現有檔案。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +使用此工具允許 LLM 建立新檔案。如果檔案已存在,則會覆蓋現有檔案。 + +:::note +`write` 工具由 `edit` 權限控制,該權限涵蓋所有檔案修改操作(`edit`、`write`、`patch`)。 +::: + +--- + +### read + +讀取程式碼庫中的檔案內容。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "read": "allow" + } +} +``` + +該工具讀取檔案並回傳其內容。它支援對大檔案讀取指定行範圍。 + +--- + +### grep + +使用正規表示式搜尋檔案內容。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "grep": "allow" + } +} +``` + +在程式碼庫中快速搜尋內容。支援完整的正規表示式語法和檔案模式過濾。 + +--- + +### glob + +透過模式匹配查找檔案。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "glob": "allow" + } +} +``` + +使用 `**/*.js` 或 `src/**/*.ts` 等 glob 模式搜尋檔案。回傳按修改時間排序的匹配檔案路徑。 + +--- + +### lsp(實驗性) + +與已設定的 LSP 伺服器互動,取得程式碼智慧功能,如定義跳轉、參考查找、懸停資訊和呼叫階層結構。 + +:::note +該工具僅在設定 `OPENCODE_EXPERIMENTAL_LSP_TOOL=true`(或 `OPENCODE_EXPERIMENTAL=true`)時可用。 +::: + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "lsp": "allow" + } +} +``` + +支援的操作包括 `goToDefinition`、`findReferences`、`hover`、`documentSymbol`、`workspaceSymbol`、`goToImplementation`、`prepareCallHierarchy`、`incomingCalls` 和 `outgoingCalls`。 + +要設定專案可用的 LSP 伺服器,請參閱 [LSP 伺服器](/docs/lsp)。 + +--- + +### patch + +對檔案套用補丁。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow" + } +} +``` + +該工具將補丁檔案套用到您的程式碼庫中。適用於套用來自各種來源的 diff 和補丁。 + +:::note +`patch` 工具由 `edit` 權限控制,該權限涵蓋所有檔案修改操作(`edit`、`write`、`patch`)。 +::: + +--- + +### skill + +載入一個[技能](/docs/skills)(即 `SKILL.md` 檔案)並在對話中回傳其內容。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "skill": "allow" + } +} +``` + +--- + +### todowrite + +在編碼工作階段中管理待辦事項清單。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "todowrite": "allow" + } +} +``` + +建立和更新任務清單以追蹤複雜操作的進度。LLM 使用此工具來組織多步驟任務。 + +:::note +該工具預設對子代理停用,但您可以手動啟用。[了解更多](/docs/agents/#permissions) +::: + +--- + +### webfetch + +擷取網頁內容。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "webfetch": "allow" + } +} +``` + +允許 LLM 擷取並讀取網頁內容。適用於查閱文件或研究線上資源。 + +--- + +### websearch + +在網路上搜尋資訊。 + +:::note +該工具僅在使用 OpenCode 供應商時,或當 `OPENCODE_ENABLE_EXA` 環境變數設定為任意真值(例如 `true` 或 `1`)時可用。 + +在啟動 OpenCode 時啟用: + +```bash +OPENCODE_ENABLE_EXA=1 opencode +``` + +::: + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "websearch": "allow" + } +} +``` + +使用 Exa AI 進行網路搜尋以查找相關資訊。適用於研究主題、了解時事動態或取得超出訓練資料截止日期的資訊。 + +無需 API 金鑰——該工具無需身分驗證即可直接連接到 Exa AI 的託管 MCP 服務。 + +:::tip +當您需要查找資訊(發現)時使用 `websearch`,當您需要從特定 URL 擷取內容(檢索)時使用 `webfetch`。 +::: + +--- + +### question + +在執行過程中向使用者提問。 + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "question": "allow" + } +} +``` + +該工具允許 LLM 在執行任務期間向使用者提問。適用於以下場景: + +- 收集使用者偏好或需求 +- 釐清模糊的指令 +- 取得實作方案的決策 +- 提供方向選擇的選項 + +每個問題包含標題、問題正文和選項清單。使用者可以從提供的選項中選擇,也可以輸入自訂答案。當有多個問題時,使用者可以在提交所有答案之前在各問題之間切換瀏覽。 + +--- + +## 自訂工具 + +自訂工具允許您定義 LLM 可以呼叫的自訂函式。這些函式在您的設定檔中定義,可以執行任意程式碼。 + +[了解更多](/docs/custom-tools)關於建立自訂工具的內容。 + +--- + +## MCP 伺服器 + +MCP(Model Context Protocol)伺服器允許您整合外部工具和服務,包括資料庫存取、API 整合和第三方服務。 + +[了解更多](/docs/mcp-servers)關於設定 MCP 伺服器的內容。 + +--- + +## 內部機制 + +在內部,`grep` 和 `glob` 等工具底層使用 [ripgrep](https://github.com/BurntSushi/ripgrep)。預設情況下,ripgrep 遵循 `.gitignore` 中的模式,這意味著 `.gitignore` 中列出的檔案和目錄將被排除在搜尋和列表結果之外。 + +--- + +### 忽略模式 + +要包含通常會被忽略的檔案,請在專案根目錄下建立一個 `.ignore` 檔案。該檔案可以明確允許某些路徑。 + +```text title=".ignore" +!node_modules/ +!dist/ +!build/ +``` + +例如,這個 `.ignore` 檔案允許 ripgrep 在 `node_modules/`、`dist/` 和 `build/` 目錄中進行搜尋,即使它們已在 `.gitignore` 中列出。