Merge branch 'dev' of github.com:anomalyco/opencode into feature/v2-plugin-model-api
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { Config } from "@/config/config"
|
||||
import z from "zod"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
@@ -49,6 +48,12 @@ export const Info = Schema.Struct({
|
||||
}).annotate({ identifier: "Agent" })
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
const GeneratedAgent = Schema.Struct({
|
||||
identifier: Schema.String,
|
||||
whenToUse: Schema.String,
|
||||
systemPrompt: Schema.String,
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (agent: string) => Effect.Effect<Info>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
@@ -405,11 +410,10 @@ export const layer = Layer.effect(
|
||||
},
|
||||
],
|
||||
model: language,
|
||||
schema: z.object({
|
||||
identifier: z.string(),
|
||||
whenToUse: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
}),
|
||||
schema: Object.assign(
|
||||
Schema.toStandardSchemaV1(GeneratedAgent),
|
||||
Schema.toStandardJSONSchemaV1(GeneratedAgent),
|
||||
),
|
||||
} satisfies Parameters<typeof generateObject>[0]
|
||||
|
||||
if (isOpenaiOauth) {
|
||||
|
||||
@@ -719,6 +719,7 @@ export const RunCommand = effectCmd({
|
||||
}
|
||||
}
|
||||
}
|
||||
return error
|
||||
}
|
||||
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
|
||||
const client = args.attach ? attachSDK(cwd) : sdk
|
||||
@@ -730,10 +731,7 @@ export const RunCommand = effectCmd({
|
||||
|
||||
if (!args.interactive) {
|
||||
const events = await client.event.subscribe()
|
||||
loop(client, events).catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
const completed = loop(client, events)
|
||||
|
||||
if (args.command) {
|
||||
await client.session.command({
|
||||
@@ -744,6 +742,8 @@ export const RunCommand = effectCmd({
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
const error = await completed
|
||||
if (error) process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
@@ -755,6 +755,8 @@ export const RunCommand = effectCmd({
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
})
|
||||
const error = await completed
|
||||
if (error) process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -989,7 +989,24 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
})
|
||||
|
||||
let submitting = false
|
||||
async function submit() {
|
||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
// input's native onSubmit racing another dispatch). Without this guard,
|
||||
// a second call slips past the empty-input check before the first call
|
||||
// clears `store.prompt.input`, then awaits its own `session.create` and
|
||||
// ultimately reads the now-empty store — sending a phantom empty prompt
|
||||
// to a freshly created session.
|
||||
if (submitting) return false
|
||||
submitting = true
|
||||
try {
|
||||
return await submitInner()
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitInner() {
|
||||
setWarpNotice(undefined)
|
||||
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
|
||||
@@ -3,33 +3,39 @@ export * as TuiKeybind from "./keybind"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import type { BindingCommandMap, BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
|
||||
import z from "zod"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const KeyStroke = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
ctrl: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
meta: z.boolean().optional(),
|
||||
super: z.boolean().optional(),
|
||||
hyper: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
const KeyStroke = Schema.Struct({
|
||||
name: Schema.String,
|
||||
ctrl: Schema.optional(Schema.Boolean),
|
||||
shift: Schema.optional(Schema.Boolean),
|
||||
meta: Schema.optional(Schema.Boolean),
|
||||
super: Schema.optional(Schema.Boolean),
|
||||
hyper: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const BindingObject = z
|
||||
.object({
|
||||
key: z.union([z.string(), KeyStroke]),
|
||||
event: z.enum(["press", "release"]).optional(),
|
||||
preventDefault: z.boolean().optional(),
|
||||
fallthrough: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
const BindingObject = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
key: Schema.Union([Schema.String, KeyStroke]),
|
||||
event: Schema.optional(Schema.Literals(["press", "release"])),
|
||||
preventDefault: Schema.optional(Schema.Boolean),
|
||||
fallthrough: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const BindingItem = z.union([z.string(), KeyStroke, BindingObject])
|
||||
export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)])
|
||||
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
|
||||
export const BindingValueSchema = Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Literal("none"),
|
||||
BindingItem,
|
||||
Schema.Array(BindingItem),
|
||||
])
|
||||
export type BindingValueSchema = DeepMutable<Schema.Schema.Type<typeof BindingValueSchema>>
|
||||
|
||||
type Definition = {
|
||||
default: z.input<typeof BindingValueSchema>
|
||||
default: BindingValueSchema
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -214,21 +220,17 @@ export const Definitions = {
|
||||
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
||||
} satisfies Record<string, Definition>
|
||||
|
||||
type KeybindName = keyof typeof Definitions & string
|
||||
type KeybindName = keyof typeof Definitions
|
||||
const KeybindNames = new Set<string>(Object.keys(Definitions))
|
||||
|
||||
const KeybindShape = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
name,
|
||||
BindingValueSchema.optional().default(item.default).describe(item.description),
|
||||
]),
|
||||
) as Record<KeybindName, z.ZodDefault<z.ZodOptional<typeof BindingValueSchema>>>
|
||||
|
||||
const KeybindOverrideShape = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]),
|
||||
) as Record<KeybindName, z.ZodOptional<typeof BindingValueSchema>>
|
||||
|
||||
export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration")
|
||||
export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides")
|
||||
export const KeybindOverrides = Schema.Struct(
|
||||
Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
name,
|
||||
Schema.optional(BindingValueSchema).annotate({ description: item.description }),
|
||||
]),
|
||||
),
|
||||
).annotate({ description: "TUI keybinding overrides" })
|
||||
export const Descriptions = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
|
||||
) as Record<KeybindName, string>
|
||||
@@ -387,8 +389,8 @@ const CommandDescriptions = Object.fromEntries(
|
||||
]),
|
||||
) as Record<string, string>
|
||||
|
||||
export type Keybinds = z.output<typeof Keybinds>
|
||||
export type KeybindOverrides = z.output<typeof KeybindOverrides>
|
||||
export type Keybinds = { [K in KeybindName]: BindingValueSchema }
|
||||
export type KeybindOverrides = Partial<Keybinds>
|
||||
export type BindingLookupView = {
|
||||
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
|
||||
get(command: string): readonly Binding<Renderable, KeyEvent>[]
|
||||
@@ -402,6 +404,29 @@ export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, K
|
||||
return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
|
||||
}
|
||||
|
||||
const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema)
|
||||
|
||||
export function defaultValue(name: KeybindName) {
|
||||
return Definitions[name].default
|
||||
}
|
||||
|
||||
export function parse(keybinds: KeybindOverrides): Keybinds {
|
||||
const invalid = unknownKeys(keybinds)
|
||||
if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`)
|
||||
return Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
name,
|
||||
decodeBindingValue(keybinds[name as KeybindName] ?? item.default),
|
||||
]),
|
||||
) as Keybinds
|
||||
}
|
||||
|
||||
export const Keybinds = { parse }
|
||||
|
||||
export function unknownKeys(input: object) {
|
||||
return Object.keys(input).filter((key) => !KeybindNames.has(key))
|
||||
}
|
||||
|
||||
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
|
||||
return ({ command, binding }) => {
|
||||
if (binding.desc !== undefined) return
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { Schema } from "effect"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
|
||||
const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
|
||||
description: "Leader key timeout in milliseconds",
|
||||
})
|
||||
|
||||
const KeyStroke = Schema.Struct({
|
||||
name: Schema.String,
|
||||
ctrl: Schema.optional(Schema.Boolean),
|
||||
shift: Schema.optional(Schema.Boolean),
|
||||
meta: Schema.optional(Schema.Boolean),
|
||||
super: Schema.optional(Schema.Boolean),
|
||||
hyper: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const BindingObject = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
key: Schema.Union([Schema.String, KeyStroke]),
|
||||
event: Schema.optional(Schema.Literals(["press", "release"])),
|
||||
preventDefault: Schema.optional(Schema.Boolean),
|
||||
fallthrough: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
|
||||
const BindingValue = Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Literal("none"),
|
||||
BindingItem,
|
||||
Schema.Array(BindingItem),
|
||||
])
|
||||
|
||||
const KeybindOverrides = Schema.Struct(
|
||||
Object.fromEntries(
|
||||
Object.entries(TuiKeybind.Definitions).map(([name, item]) => [
|
||||
name,
|
||||
Schema.optional(BindingValue).annotate({ description: item.description }),
|
||||
]),
|
||||
),
|
||||
).annotate({ description: "TUI keybinding overrides" })
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String),
|
||||
theme: Schema.optional(Schema.String),
|
||||
keybinds: Schema.optional(KeybindOverrides),
|
||||
plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)),
|
||||
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
leader_timeout: Schema.optional(KeymapLeaderTimeout),
|
||||
scroll_speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({
|
||||
description: "TUI scroll speed",
|
||||
}),
|
||||
scroll_acceleration: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
|
||||
}),
|
||||
).annotate({ description: "Scroll acceleration settings" }),
|
||||
diff_style: Schema.optional(Schema.Literals(["auto", "stacked"])).annotate({
|
||||
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
||||
}),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
|
||||
})
|
||||
|
||||
export * as TuiJsonSchema from "./tui-json-schema"
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from "path"
|
||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||
import { unique } from "remeda"
|
||||
import z from "zod"
|
||||
import { TuiInfo, TuiOptions } from "./tui-schema"
|
||||
import { Option, Schema } from "effect"
|
||||
import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -13,16 +13,11 @@ const log = Log.create({ service: "tui.migrate" })
|
||||
|
||||
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
|
||||
|
||||
const LegacyTheme = TuiInfo.shape.theme.optional()
|
||||
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
|
||||
|
||||
const TuiLegacy = z
|
||||
.object({
|
||||
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
|
||||
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
|
||||
diff_style: TuiOptions.shape.diff_style.catch(undefined),
|
||||
})
|
||||
.strip()
|
||||
const decodeTheme = Schema.decodeUnknownOption(Schema.String)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed)
|
||||
const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration)
|
||||
const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle)
|
||||
|
||||
interface MigrateInput {
|
||||
cwd: string
|
||||
@@ -46,13 +41,13 @@ export async function migrateTuiConfig(input: MigrateInput) {
|
||||
const data = parseJsonc(source, errors, { allowTrailingComma: true })
|
||||
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
|
||||
|
||||
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
|
||||
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
|
||||
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
|
||||
const theme = decodeTheme("theme" in data ? data.theme : undefined)
|
||||
const keybinds = decodeRecord("keybinds" in data ? data.keybinds : undefined)
|
||||
const legacyTui = decodeRecord("tui" in data ? data.tui : undefined)
|
||||
const extracted = {
|
||||
theme: theme.success ? theme.data : undefined,
|
||||
keybinds: keybinds.success ? keybinds.data : undefined,
|
||||
tui: legacyTui.success ? legacyTui.data : undefined,
|
||||
theme: Option.getOrUndefined(theme),
|
||||
keybinds: Option.getOrUndefined(keybinds),
|
||||
tui: Option.getOrUndefined(legacyTui),
|
||||
}
|
||||
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
|
||||
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
|
||||
@@ -85,16 +80,23 @@ export async function migrateTuiConfig(input: MigrateInput) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTui(data: Record<string, unknown>) {
|
||||
const parsed = TuiLegacy.parse(data)
|
||||
if (
|
||||
parsed.scroll_speed === undefined &&
|
||||
function normalizeTui(data: Record<string, unknown>):
|
||||
| {
|
||||
scroll_speed: number | undefined
|
||||
scroll_acceleration: { enabled: boolean } | undefined
|
||||
diff_style: "auto" | "stacked" | undefined
|
||||
}
|
||||
| undefined {
|
||||
const parsed = {
|
||||
scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)),
|
||||
scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)),
|
||||
diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)),
|
||||
}
|
||||
return parsed.scroll_speed === undefined &&
|
||||
parsed.diff_style === undefined &&
|
||||
parsed.scroll_acceleration === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
return parsed
|
||||
? undefined
|
||||
: parsed
|
||||
}
|
||||
|
||||
async function backupAndStripLegacy(file: string, source: string) {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import z from "zod"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const KeymapLeaderTimeoutDefault = 2000
|
||||
const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds")
|
||||
|
||||
export const TuiOptions = z.object({
|
||||
leader_timeout: KeymapLeaderTimeout.optional(),
|
||||
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
||||
scroll_acceleration: z
|
||||
.object({
|
||||
enabled: z.boolean().describe("Enable scroll acceleration"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Scroll acceleration settings"),
|
||||
diff_style: z
|
||||
.enum(["auto", "stacked"])
|
||||
.optional()
|
||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
|
||||
const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
|
||||
description: "Leader key timeout in milliseconds",
|
||||
})
|
||||
|
||||
export const TuiInfo = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: TuiKeybind.KeybindOverrides.optional(),
|
||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
.strict()
|
||||
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
|
||||
|
||||
export const ScrollAcceleration = Schema.Struct({
|
||||
enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
|
||||
}).annotate({ description: "Scroll acceleration settings" })
|
||||
|
||||
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
|
||||
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
||||
})
|
||||
|
||||
export const TuiInfo = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String),
|
||||
theme: Schema.optional(Schema.String),
|
||||
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
|
||||
plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)),
|
||||
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
leader_timeout: Schema.optional(KeymapLeaderTimeout),
|
||||
scroll_speed: Schema.optional(ScrollSpeed).annotate({
|
||||
description: "TUI scroll speed",
|
||||
}),
|
||||
scroll_acceleration: Schema.optional(ScrollAcceleration),
|
||||
diff_style: Schema.optional(DiffStyle),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import type z from "zod"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
import { Context, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { ConfigParse } from "@/config/parse"
|
||||
import { InvalidError } from "@/config/error"
|
||||
import * as ConfigPaths from "@/config/paths"
|
||||
@@ -22,11 +21,12 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
export type Info = z.output<typeof Info>
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
@@ -91,9 +91,17 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
if (!isRecord(data)) return {} as Info
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const parsed = Info.safeParse(normalize(data))
|
||||
if (!parsed.success) throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
|
||||
const validated = parsed.data
|
||||
const normalized = normalize(data)
|
||||
if (isRecord(normalized.keybinds)) {
|
||||
const invalid = TuiKeybind.unknownKeys(normalized.keybinds)
|
||||
if (invalid.length) {
|
||||
throw new InvalidError({
|
||||
path: configFilepath,
|
||||
message: `Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const validated = ConfigParse.schema(Info, normalized, configFilepath)
|
||||
return yield* resolvePlugins(validated, configFilepath)
|
||||
}).pipe(
|
||||
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
|
||||
@@ -179,16 +187,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
}
|
||||
}
|
||||
|
||||
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||
const keybinds = { ...acc.result.keybinds }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique([
|
||||
"ctrl+z",
|
||||
...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","),
|
||||
]).join(",")
|
||||
const inputUndo = TuiKeybind.defaultValue("input_undo")
|
||||
keybinds.input_undo ??= unique(["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]).join(",")
|
||||
}
|
||||
const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds)
|
||||
const parsedKeybinds = TuiKeybind.parse(keybinds)
|
||||
const result: Resolved = {
|
||||
...acc.result,
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import z from "zod"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { EditorSelection } from "./editor"
|
||||
|
||||
const ZedEditorRowSchema = z.object({
|
||||
item_kind: z.string(),
|
||||
editor_id: z.number().nullable(),
|
||||
workspace_id: z.number(),
|
||||
workspace_paths: z.string().nullable(),
|
||||
timestamp: z.string(),
|
||||
buffer_path: z.string().nullable(),
|
||||
const ZedEditorRowSchema = Schema.Struct({
|
||||
item_kind: Schema.String,
|
||||
editor_id: Schema.NullOr(Schema.Number),
|
||||
workspace_id: Schema.Number,
|
||||
workspace_paths: Schema.NullOr(Schema.String),
|
||||
timestamp: Schema.String,
|
||||
buffer_path: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
const ZedSelectionRowSchema = z.object({
|
||||
selection_start: z.number().nullable(),
|
||||
selection_end: z.number().nullable(),
|
||||
const ZedSelectionRowSchema = Schema.Struct({
|
||||
selection_start: Schema.NullOr(Schema.Number),
|
||||
selection_end: Schema.NullOr(Schema.Number),
|
||||
})
|
||||
|
||||
const ZedEditorContentsSchema = z.object({
|
||||
contents: z.string().nullable(),
|
||||
const ZedEditorContentsSchema = Schema.Struct({
|
||||
contents: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
|
||||
const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
|
||||
const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
|
||||
|
||||
const utf8 = new TextEncoder()
|
||||
|
||||
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
||||
type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
|
||||
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
|
||||
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
|
||||
|
||||
export type ZedSelectionResult =
|
||||
| { type: "selection"; selection: EditorSelection }
|
||||
@@ -107,8 +110,8 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
.all()
|
||||
|
||||
const rows = raw.flatMap((row) => {
|
||||
const parsed = ZedEditorRowSchema.safeParse(row)
|
||||
return parsed.success ? [parsed.data] : []
|
||||
const parsed = decodeZedEditorRow(row)
|
||||
return Option.isSome(parsed) ? [parsed.value] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
|
||||
@@ -143,8 +146,8 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
|
||||
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
|
||||
|
||||
const selections = raw.flatMap((selection) => {
|
||||
const parsed = ZedSelectionRowSchema.safeParse(selection)
|
||||
return parsed.success ? [parsed.data] : []
|
||||
const parsed = decodeZedSelectionRow(selection)
|
||||
return Option.isSome(parsed) ? [parsed.value] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
|
||||
@@ -160,7 +163,7 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const parsed = ZedEditorContentsSchema.safeParse(
|
||||
const parsed = decodeZedEditorContents(
|
||||
db
|
||||
.query(
|
||||
`select contents
|
||||
@@ -169,8 +172,8 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
||||
)
|
||||
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
||||
)
|
||||
if (!parsed.success) return { type: "unavailable" as const }
|
||||
return { type: "contents" as const, contents: parsed.data.contents }
|
||||
if (Option.isNone(parsed)) return { type: "unavailable" as const }
|
||||
return { type: "contents" as const, contents: parsed.value.contents }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
|
||||
@@ -3,92 +3,102 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import z from "zod"
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||
|
||||
const JsonRpcMessageSchema = z.object({
|
||||
id: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
method: z.string().optional(),
|
||||
params: z.unknown().optional(),
|
||||
result: z.unknown().optional(),
|
||||
error: z
|
||||
.object({
|
||||
code: z.number().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
const JsonRpcMessageSchema = Schema.Struct({
|
||||
id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])),
|
||||
method: Schema.optional(Schema.String),
|
||||
params: Schema.optional(Schema.Unknown),
|
||||
result: Schema.optional(Schema.Unknown),
|
||||
error: Schema.optional(
|
||||
Schema.Struct({
|
||||
code: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const PositionSchema = z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
const PositionSchema = Schema.Struct({
|
||||
line: Schema.Number,
|
||||
character: Schema.Number,
|
||||
})
|
||||
|
||||
const EditorSelectionRangeSchema = z.object({
|
||||
text: z.string(),
|
||||
selection: z.object({
|
||||
const EditorSelectionRangeSchema = Schema.Struct({
|
||||
text: Schema.String,
|
||||
selection: Schema.Struct({
|
||||
start: PositionSchema,
|
||||
end: PositionSchema,
|
||||
}),
|
||||
})
|
||||
|
||||
const EditorSelectionSchema = z
|
||||
.union([
|
||||
z.object({
|
||||
filePath: z.string(),
|
||||
source: z.enum(["websocket", "zed"]).optional(),
|
||||
ranges: z.array(EditorSelectionRangeSchema).min(1),
|
||||
}),
|
||||
z.object({
|
||||
text: z.string(),
|
||||
filePath: z.string(),
|
||||
source: z.enum(["websocket", "zed"]).optional(),
|
||||
selection: z.object({
|
||||
start: PositionSchema,
|
||||
end: PositionSchema,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
.transform((value) =>
|
||||
"ranges" in value
|
||||
? value
|
||||
: {
|
||||
filePath: value.filePath,
|
||||
source: value.source,
|
||||
ranges: [
|
||||
{
|
||||
text: value.text,
|
||||
selection: value.selection,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
const EditorMentionSchema = z.object({
|
||||
filePath: z.string(),
|
||||
lineStart: z.number(),
|
||||
lineEnd: z.number(),
|
||||
const EditorSelectionRangesSchema = Schema.Struct({
|
||||
filePath: Schema.String,
|
||||
source: Schema.optional(Schema.Literals(["websocket", "zed"])),
|
||||
ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))),
|
||||
})
|
||||
|
||||
const EditorServerInfoSchema = z.object({
|
||||
protocolVersion: z.string().optional(),
|
||||
serverInfo: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
const EditorSelectionSchema = Schema.Union([
|
||||
EditorSelectionRangesSchema,
|
||||
Schema.Struct({
|
||||
text: Schema.String,
|
||||
filePath: Schema.String,
|
||||
source: Schema.optional(Schema.Literals(["websocket", "zed"])),
|
||||
selection: Schema.Struct({
|
||||
start: PositionSchema,
|
||||
end: PositionSchema,
|
||||
}),
|
||||
}),
|
||||
]).pipe(
|
||||
Schema.decodeTo(EditorSelectionRangesSchema, {
|
||||
decode: SchemaGetter.transform((value) =>
|
||||
"ranges" in value
|
||||
? value
|
||||
: {
|
||||
filePath: value.filePath,
|
||||
source: value.source,
|
||||
ranges: [
|
||||
{
|
||||
text: value.text,
|
||||
selection: value.selection,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
|
||||
const EditorMentionSchema = Schema.Struct({
|
||||
filePath: Schema.String,
|
||||
lineStart: Schema.Number,
|
||||
lineEnd: Schema.Number,
|
||||
})
|
||||
|
||||
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
|
||||
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
|
||||
export type EditorMention = z.infer<typeof EditorMentionSchema>
|
||||
const EditorServerInfoSchema = Schema.Struct({
|
||||
protocolVersion: Schema.optional(Schema.String),
|
||||
serverInfo: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema)
|
||||
const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema)
|
||||
const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema)
|
||||
const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema)
|
||||
|
||||
type JsonRpcMessage = Schema.Schema.Type<typeof JsonRpcMessageSchema>
|
||||
export type EditorSelection = Schema.Schema.Type<typeof EditorSelectionSchema>
|
||||
export type EditorMention = Schema.Schema.Type<typeof EditorMentionSchema>
|
||||
export type EditorLabelState = "pending" | "sent" | "none"
|
||||
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
|
||||
type EditorServerInfo = Schema.Schema.Type<typeof EditorServerInfoSchema>
|
||||
|
||||
type EditorConnection = {
|
||||
url: string
|
||||
@@ -214,16 +224,15 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
const message = parseMessage(event.data)
|
||||
if (!message) return
|
||||
|
||||
const selection =
|
||||
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
||||
if (selection?.success) {
|
||||
setSelection({ ...selection.data, source: "websocket" })
|
||||
const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none()
|
||||
if (Option.isSome(selection)) {
|
||||
setSelection({ ...selection.value, source: "websocket" })
|
||||
return
|
||||
}
|
||||
|
||||
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
|
||||
if (mention?.success) {
|
||||
mentionListeners.forEach((listener) => listener(mention.data))
|
||||
const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none()
|
||||
if (Option.isSome(mention)) {
|
||||
mentionListeners.forEach((listener) => listener(mention.value))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -235,9 +244,9 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
pending.delete(message.id)
|
||||
if (message.error) return
|
||||
|
||||
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
|
||||
if (initialize?.success) {
|
||||
setStore("server", initialize.data)
|
||||
const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none()
|
||||
if (Option.isSome(initialize)) {
|
||||
setStore("server", initialize.value)
|
||||
send({ method: "notifications/initialized" })
|
||||
return
|
||||
}
|
||||
@@ -447,7 +456,7 @@ function parseMessage(value: unknown) {
|
||||
if (typeof value !== "string") return
|
||||
|
||||
try {
|
||||
return JsonRpcMessageSchema.parse(JSON.parse(value))
|
||||
return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value)))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,39 +2,33 @@ import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { useProject } from "./project"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type EventMetadata = {
|
||||
workspace: string | undefined
|
||||
}
|
||||
|
||||
export function useEvent() {
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
function subscribe(handler: (event: Event) => void) {
|
||||
function subscribe(handler: (event: Event, metadata: EventMetadata) => void) {
|
||||
return sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "sync") {
|
||||
return
|
||||
}
|
||||
|
||||
// Special hack for truly global events
|
||||
if (event.directory === "global") {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
if (project.workspace.current()) {
|
||||
if (event.workspace === project.workspace.current()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.directory === project.instance.directory()) {
|
||||
handler(event.payload)
|
||||
if (event.directory === "global" || event.project === project.project()) {
|
||||
handler(event.payload, { workspace: event.workspace })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
|
||||
return subscribe((event) => {
|
||||
function on<T extends Event["type"]>(
|
||||
type: T,
|
||||
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
|
||||
) {
|
||||
return subscribe((event: Event, metadata: EventMetadata) => {
|
||||
if (event.type !== type) return
|
||||
handler(event as Extract<Event, { type: T }>)
|
||||
handler(event as Extract<Event, { type: T }>, metadata)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const kv = useKV()
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
let syncedWorkspace = project.workspace.current()
|
||||
|
||||
function sessionListQuery(): { scope?: "project"; path?: string } {
|
||||
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
|
||||
@@ -131,7 +130,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
event.subscribe((event, { workspace }) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
void bootstrap()
|
||||
@@ -364,7 +363,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
case "vcs.branch.updated": {
|
||||
setStore("vcs", { branch: event.properties.branch })
|
||||
if (workspace === project.workspace.current()) {
|
||||
setStore("vcs", { branch: event.properties.branch })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -376,10 +377,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
async function bootstrap(input: { fatal?: boolean } = {}) {
|
||||
const fatal = input.fatal ?? true
|
||||
const workspace = project.workspace.current()
|
||||
if (workspace !== syncedWorkspace) {
|
||||
fullSyncedSessions.clear()
|
||||
syncedWorkspace = workspace
|
||||
}
|
||||
const projectPromise = project.sync()
|
||||
const sessionListPromise = projectPromise.then(() => listSessions())
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
untrack,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -241,7 +242,7 @@ export function Session() {
|
||||
createEffect(() => {
|
||||
const sessionID = route.sessionID
|
||||
void (async () => {
|
||||
const previousWorkspace = project.workspace.current()
|
||||
const previousWorkspace = untrack(() => project.workspace.current())
|
||||
const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
|
||||
if (!result.data) {
|
||||
toast.show({
|
||||
|
||||
@@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
@@ -35,12 +33,11 @@ export const Info = Schema.Struct({
|
||||
model: Schema.optional(Schema.String),
|
||||
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
|
||||
// Some command templates are lazy promises from MCP prompt resolution.
|
||||
template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
|
||||
template: Schema.Unknown,
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
hints: Schema.Array(Schema.String),
|
||||
}).annotate({ identifier: "Command" })
|
||||
|
||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||
export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||
|
||||
export function hints(template: string) {
|
||||
|
||||
@@ -409,6 +409,16 @@ export const layer = Layer.effect(
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
let result: Info = {}
|
||||
// Seed the default global config with the schema for editor completion, but avoid writing when the user
|
||||
// explicitly routes config through env-provided paths or content.
|
||||
if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) {
|
||||
const file = globalConfigFile()
|
||||
if (!existsSync(file)) {
|
||||
yield* fs
|
||||
.writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
}
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
// The original Zod schema carried an external $ref pointing at the models.dev
|
||||
// JSON schema. That external reference is not a named SDK component — it is a
|
||||
// literal pointer to an outside schema — so the walker cannot re-derive it
|
||||
// from AST metadata. Preserve the exact original Zod via ZodOverride.
|
||||
export const ConfigModelID = Schema.String.annotate({
|
||||
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
|
||||
})
|
||||
export const ConfigModelID = Schema.String
|
||||
|
||||
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Schema } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import path from "path"
|
||||
|
||||
export const Options = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -11,9 +9,7 @@ export type Options = Schema.Schema.Type<typeof Options>
|
||||
|
||||
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
|
||||
// It answers "what should we load?" but says nothing about where that value came from.
|
||||
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))])
|
||||
export type Spec = Schema.Schema.Type<typeof Spec>
|
||||
|
||||
export type Scope = "global" | "local"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { readdir } from "fs/promises"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -45,15 +44,11 @@ export function getReleaseType(current: string, latest: string): ReleaseType {
|
||||
return "patch"
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
latest: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "InstallationInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Struct({
|
||||
version: Schema.String,
|
||||
latest: Schema.String,
|
||||
}).annotate({ identifier: "InstallationInfo" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as LSPClient from "./client"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import * as LSPServer from "./server"
|
||||
import z from "zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Process } from "@/util/process"
|
||||
@@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { containsPath } from "@/project/instance-context"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
@@ -56,9 +54,7 @@ export const Status = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
root: Schema.String,
|
||||
status: Schema.Literals(["connected", "error"]).annotate({
|
||||
[ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
|
||||
}),
|
||||
status: Schema.Literals(["connected", "error"]),
|
||||
}).annotate({ identifier: "LSPStatus" })
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Option, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
export const Tokens = z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
scope: z.string().optional(),
|
||||
export const Tokens = Schema.Struct({
|
||||
accessToken: Schema.mutableKey(Schema.String),
|
||||
refreshToken: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
scope: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
})
|
||||
export type Tokens = z.infer<typeof Tokens>
|
||||
export type Tokens = Schema.Schema.Type<typeof Tokens>
|
||||
|
||||
export const ClientInfo = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
clientIdIssuedAt: z.number().optional(),
|
||||
clientSecretExpiresAt: z.number().optional(),
|
||||
export const ClientInfo = Schema.Struct({
|
||||
clientId: Schema.mutableKey(Schema.String),
|
||||
clientSecret: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
})
|
||||
export type ClientInfo = z.infer<typeof ClientInfo>
|
||||
export type ClientInfo = Schema.Schema.Type<typeof ClientInfo>
|
||||
|
||||
export const Entry = z.object({
|
||||
tokens: Tokens.optional(),
|
||||
clientInfo: ClientInfo.optional(),
|
||||
codeVerifier: z.string().optional(),
|
||||
oauthState: z.string().optional(),
|
||||
serverUrl: z.string().optional(),
|
||||
export const Entry = Schema.Struct({
|
||||
tokens: Schema.mutableKey(Schema.optional(Tokens)),
|
||||
clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)),
|
||||
codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
oauthState: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
serverUrl: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
})
|
||||
export type Entry = z.infer<typeof Entry>
|
||||
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||
|
||||
const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
|
||||
type AuthData = Record<string, Entry>
|
||||
|
||||
const filepath = path.join(Global.Path.data, "mcp-auth.json")
|
||||
|
||||
@@ -56,8 +58,8 @@ export const layer = Layer.effect(
|
||||
|
||||
const all = Effect.fn("McpAuth.all")(function* () {
|
||||
return yield* fs.readJson(filepath).pipe(
|
||||
Effect.map((data) => data as Record<string, Entry>),
|
||||
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
|
||||
Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData),
|
||||
Effect.catch(() => Effect.succeed({} as AuthData)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -93,7 +95,7 @@ export const layer = Layer.effect(
|
||||
yield* set(mcpName, entry, serverUrl)
|
||||
})
|
||||
|
||||
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
|
||||
const clearField = (field: keyof Entry, spanName: string) =>
|
||||
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
|
||||
const entry = yield* get(mcpName)
|
||||
if (entry) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { readFileSync } from "fs"
|
||||
@@ -7,12 +7,11 @@ import * as Bom from "../util/bom"
|
||||
|
||||
const log = Log.create({ service: "patch" })
|
||||
|
||||
// Schema definitions
|
||||
export const PatchSchema = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
export const PatchSchema = Schema.Struct({
|
||||
patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }),
|
||||
})
|
||||
|
||||
export type PatchParams = z.infer<typeof PatchSchema>
|
||||
export type PatchParams = Schema.Schema.Type<typeof PatchSchema>
|
||||
|
||||
// Core types matching the Rust implementation
|
||||
export interface ApplyPatchArgs {
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
import { z } from "zod"
|
||||
import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const schema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
model_picker_enabled: z.boolean(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
export const schema = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
model_picker_enabled: Schema.Boolean,
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
// every version looks like: `{model.id}-YYYY-MM-DD`
|
||||
version: z.string(),
|
||||
supported_endpoints: z.array(z.string()).optional(),
|
||||
policy: z
|
||||
.object({
|
||||
state: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
capabilities: z.object({
|
||||
family: z.string(),
|
||||
limits: z.object({
|
||||
max_context_window_tokens: z.number(),
|
||||
max_output_tokens: z.number(),
|
||||
max_prompt_tokens: z.number(),
|
||||
vision: z
|
||||
.object({
|
||||
max_prompt_image_size: z.number(),
|
||||
max_prompt_images: z.number(),
|
||||
supported_media_types: z.array(z.string()),
|
||||
})
|
||||
.optional(),
|
||||
version: Schema.String,
|
||||
supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
|
||||
policy: Schema.optional(
|
||||
Schema.Struct({
|
||||
state: Schema.optional(Schema.String),
|
||||
}),
|
||||
supports: z.object({
|
||||
adaptive_thinking: z.boolean().optional(),
|
||||
max_thinking_budget: z.number().optional(),
|
||||
min_thinking_budget: z.number().optional(),
|
||||
reasoning_effort: z.array(z.string()).optional(),
|
||||
streaming: z.boolean(),
|
||||
structured_outputs: z.boolean().optional(),
|
||||
tool_calls: z.boolean(),
|
||||
vision: z.boolean().optional(),
|
||||
),
|
||||
capabilities: Schema.Struct({
|
||||
family: Schema.String,
|
||||
limits: Schema.Struct({
|
||||
max_context_window_tokens: Schema.Number,
|
||||
max_output_tokens: Schema.Number,
|
||||
max_prompt_tokens: Schema.Number,
|
||||
vision: Schema.optional(
|
||||
Schema.Struct({
|
||||
max_prompt_image_size: Schema.Number,
|
||||
max_prompt_images: Schema.Number,
|
||||
supported_media_types: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
supports: Schema.Struct({
|
||||
adaptive_thinking: Schema.optional(Schema.Boolean),
|
||||
max_thinking_budget: Schema.optional(Schema.Number),
|
||||
min_thinking_budget: Schema.optional(Schema.Number),
|
||||
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
|
||||
streaming: Schema.Boolean,
|
||||
structured_outputs: Schema.optional(Schema.Boolean),
|
||||
tool_calls: Schema.Boolean,
|
||||
vision: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
type Item = z.infer<typeof schema>["data"][number]
|
||||
type Item = Schema.Schema.Type<typeof schema>["data"][number]
|
||||
const decodeModels = Schema.decodeUnknownSync(schema)
|
||||
|
||||
function build(key: string, remote: Item, url: string, prev?: Model): Model {
|
||||
const reasoning =
|
||||
@@ -165,7 +166,7 @@ export async function get(
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch models: ${res.status}`)
|
||||
}
|
||||
return schema.parse(await res.json())
|
||||
return decodeModels(await res.json())
|
||||
})
|
||||
|
||||
const result = { ...existing }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ModelMessage, ToolResultPart } from "ai"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type { JSONSchema } from "zod/v4/core"
|
||||
import type * as Provider from "./provider"
|
||||
import type * as ModelsDev from "./models"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -1281,7 +1280,7 @@ export function maxOutputTokens(model: Provider.Model): number {
|
||||
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
|
||||
}
|
||||
|
||||
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 {
|
||||
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
|
||||
/*
|
||||
if (["openai", "azure"].includes(providerID)) {
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
@@ -1312,7 +1311,10 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS
|
||||
return result
|
||||
}
|
||||
|
||||
schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7
|
||||
const sanitized = sanitizeMoonshot(schema)
|
||||
if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) {
|
||||
schema = sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Convert integer enums to string enums for Google/Gemini
|
||||
@@ -1394,7 +1396,7 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS
|
||||
schema = sanitizeGemini(schema)
|
||||
}
|
||||
|
||||
return schema as JSONSchema7
|
||||
return schema
|
||||
}
|
||||
|
||||
export * as ProviderTransform from "./transform"
|
||||
|
||||
@@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Project } from "@/project/project"
|
||||
import { Session } from "@/session/session"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Effect, Option } from "effect"
|
||||
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
||||
@@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
return list.map((item) => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
parameters: EffectZod.toJsonSchema(item.parameters),
|
||||
parameters: ToolJsonSchema.fromTool(item),
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
@@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", {
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type APIError = z.infer<typeof APIError.Schema>
|
||||
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
|
||||
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
|
||||
message: Schema.String,
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MCP } from "../mcp"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -566,7 +566,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema),
|
||||
|
||||
@@ -54,19 +54,20 @@ export const GrepTool = Tool.define(
|
||||
})
|
||||
|
||||
const ins = yield* InstanceState.context
|
||||
const search = AppFileSystem.resolve(
|
||||
path.isAbsolute(params.path ?? ins.directory)
|
||||
? (params.path ?? ins.directory)
|
||||
: path.join(ins.directory, params.path ?? "."),
|
||||
)
|
||||
yield* reference.ensure(search)
|
||||
const requested = path.isAbsolute(params.path ?? ins.directory)
|
||||
? (params.path ?? ins.directory)
|
||||
: path.join(ins.directory, params.path ?? ".")
|
||||
yield* reference.ensure(requested)
|
||||
const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
yield* assertExternalDirectoryEffect(ctx, requested, {
|
||||
bypass: yield* reference.contains(requested),
|
||||
kind: requestedInfo?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
const search = AppFileSystem.resolve(requested)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
bypass: yield* reference.contains(search),
|
||||
kind: info?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
const result = yield* rg.search({
|
||||
cwd,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import { JsonSchema, Schema } from "effect"
|
||||
import type * as Tool from "./tool"
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
const cache = new WeakMap<Schema.Top, JSONSchema7>()
|
||||
|
||||
export function fromSchema(schema: Schema.Top): JSONSchema7 {
|
||||
const cached = cache.get(schema)
|
||||
if (cached) return cached
|
||||
|
||||
const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true })
|
||||
const result = normalize({
|
||||
$schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
|
||||
...document.schema,
|
||||
...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}),
|
||||
})
|
||||
const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result))
|
||||
if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value")
|
||||
cache.set(schema, inlined)
|
||||
return inlined
|
||||
}
|
||||
|
||||
export function fromTool(tool: Tool.Def): JSONSchema7 {
|
||||
return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top)
|
||||
}
|
||||
|
||||
function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => normalize(item))
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const required = Array.isArray(value.required)
|
||||
? new Set(value.required.filter((item) => typeof item === "string"))
|
||||
: undefined
|
||||
const schema = Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
key === "properties" && isRecord(item)
|
||||
? Object.fromEntries(
|
||||
Object.entries(item).map(([name, property]) => [
|
||||
name,
|
||||
normalize(property, { stripNull: !required?.has(name) }),
|
||||
]),
|
||||
)
|
||||
: normalize(item),
|
||||
]),
|
||||
)
|
||||
|
||||
if (schema.additionalProperties === true) delete schema.additionalProperties
|
||||
|
||||
if (options.stripNull && Array.isArray(schema.anyOf)) {
|
||||
const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null")
|
||||
if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull })
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
const withoutNull = schema.anyOf
|
||||
const number = withoutNull.find((item) => isRecord(item) && item.type === "number")
|
||||
const nonFinite = withoutNull.filter(
|
||||
(item) => isRecord(item) && Array.isArray(item.enum) && item.enum.every((entry) => isNonFiniteNumber(entry)),
|
||||
)
|
||||
if (number && nonFinite.length === withoutNull.length - 1) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ ...number, ...rest })
|
||||
}
|
||||
|
||||
if (isEmptyStructUnion(withoutNull)) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ type: "object", properties: {}, ...rest })
|
||||
}
|
||||
|
||||
if (withoutNull.length === 1 && isRecord(withoutNull[0])) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ ...withoutNull[0], ...rest })
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) {
|
||||
const { allOf, ...rest } = schema
|
||||
return normalize({ ...Object.assign({}, ...allOf), ...rest })
|
||||
}
|
||||
|
||||
if (schema.type === "integer" && schema.maximum === undefined) {
|
||||
return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER }
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isJsonSchema(value: unknown): value is JSONSchema7 {
|
||||
return typeof value === "boolean" || isRecord(value)
|
||||
}
|
||||
|
||||
function isNonFiniteNumber(value: unknown) {
|
||||
return value === "NaN" || value === "Infinity" || value === "-Infinity"
|
||||
}
|
||||
|
||||
function isEmptyStructUnion(items: unknown[]) {
|
||||
return (
|
||||
items.length === 2 &&
|
||||
items.some((item) => isRecord(item) && item.type === "object" && item.properties === undefined) &&
|
||||
items.some((item) => isRecord(item) && item.type === "array" && item.items === undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function canFlattenAllOf(allOf: JsonObject[], parent: JsonObject) {
|
||||
const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf"))
|
||||
return allOf.every((item) =>
|
||||
Object.keys(item).every((key) => {
|
||||
if (keys.has(key)) return false
|
||||
keys.add(key)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function inlineLocalReferences(value: unknown, definitions?: JsonObject, seen = new Set<string>()): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen))
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined)
|
||||
if (typeof value.$ref === "string" && localDefinitions) {
|
||||
const name = value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? value.$ref.match(/^#\/definitions\/(.+)$/)?.[1]
|
||||
if (name && !seen.has(name)) {
|
||||
const target = localDefinitions[name]
|
||||
if (target) {
|
||||
const { $ref: _, ...rest } = value
|
||||
return inlineLocalReferences(
|
||||
{ ...(isRecord(target) ? target : {}), ...rest },
|
||||
localDefinitions,
|
||||
new Set(seen).add(name),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]),
|
||||
)
|
||||
}
|
||||
|
||||
function dropDefinitionsIfResolved(value: unknown): unknown {
|
||||
if (!isRecord(value) || hasLocalReference(value)) return value
|
||||
const { $defs: _, definitions: __, ...rest } = value
|
||||
return rest
|
||||
}
|
||||
|
||||
function hasLocalReference(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(hasLocalReference)
|
||||
if (!isRecord(value)) return false
|
||||
if (
|
||||
typeof value.$ref === "string" &&
|
||||
(value.$ref.startsWith("#/$defs/") || value.$ref.startsWith("#/definitions/"))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return Object.values(value).some(hasLocalReference)
|
||||
}
|
||||
|
||||
export * as ToolJsonSchema from "./json-schema"
|
||||
@@ -15,9 +15,9 @@ import { SkillTool } from "./skill"
|
||||
import * as Tool from "./tool"
|
||||
import { Config } from "@/config/config"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
@@ -137,17 +137,19 @@ export const layer: Layer.Layer<
|
||||
const custom: Tool.Def[] = []
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
||||
// Plugin tools define their args as a raw Zod shape. Wrap the
|
||||
// derived Zod object in a `Schema.declare` so it slots into the
|
||||
// Schema-typed framework, and annotate with `ZodOverride` so the
|
||||
// walker emits the original Zod object for LLM JSON Schema.
|
||||
const zodParams = z.object(def.args)
|
||||
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
|
||||
[ZodOverride]: zodParams,
|
||||
})
|
||||
// Plugin tools still expose Zod args publicly; keep that compatibility
|
||||
// boxed at the registry boundary and give the LLM the original JSON Schema.
|
||||
const entries = Object.entries(def.args)
|
||||
const allZod = entries.every((entry) => isZodType(entry[1]))
|
||||
const zodParams = allZod ? z.object(def.args) : undefined
|
||||
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
||||
const parameters = zodParams
|
||||
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
||||
: Schema.Unknown
|
||||
return {
|
||||
id,
|
||||
parameters,
|
||||
jsonSchema,
|
||||
description: def.description,
|
||||
execute: (args, toolCtx) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -323,8 +325,13 @@ export const layer: Layer.Layer<
|
||||
const output = {
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
jsonSchema: tool.jsonSchema,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
const jsonSchema =
|
||||
output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
|
||||
? output.jsonSchema
|
||||
: undefined
|
||||
return {
|
||||
id: tool.id,
|
||||
description: [
|
||||
@@ -335,6 +342,7 @@ export const layer: Layer.Layer<
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
jsonSchema,
|
||||
execute: tool.execute,
|
||||
formatValidationError: tool.formatValidationError,
|
||||
}
|
||||
@@ -376,4 +384,50 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
)
|
||||
|
||||
function isZodType(value: unknown): value is z.ZodType {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
|
||||
function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition {
|
||||
return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value))
|
||||
}
|
||||
|
||||
function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
|
||||
const properties = Object.fromEntries(
|
||||
entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])),
|
||||
)
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required: Object.keys(properties),
|
||||
}
|
||||
}
|
||||
|
||||
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
|
||||
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" }))
|
||||
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
|
||||
const { $defs, ...rest } = result
|
||||
return (
|
||||
$defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest
|
||||
) as JSONSchema7
|
||||
}
|
||||
|
||||
function normalizeZodJsonSchema(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item))
|
||||
if (typeof value !== "object" || value === null) return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter((entry) =>
|
||||
(entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean"
|
||||
? false
|
||||
: true,
|
||||
)
|
||||
.map(([key, item]) => [key, normalizeZodJsonSchema(item)]),
|
||||
)
|
||||
}
|
||||
|
||||
function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export * as ToolRegistry from "./registry"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
@@ -38,6 +39,7 @@ export interface Def<
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
jsonSchema?: JSONSchema7
|
||||
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
|
||||
formatValidationError?(error: unknown): string
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||
export const Parameters = Schema.Struct({
|
||||
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
|
||||
format: Schema.Literals(["text", "markdown", "html"])
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const)))
|
||||
.annotate({
|
||||
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
}),
|
||||
default: "markdown",
|
||||
})
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
/**
|
||||
* Create a Schema-backed NamedError-shaped class.
|
||||
@@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod"
|
||||
* OpenAPI/SDK output is byte-identical to the original NamedError schema.
|
||||
*
|
||||
* Preserves the existing surface:
|
||||
* - static `Schema` (Zod schema of the wire shape)
|
||||
* - static `Schema` (Effect schema of the wire shape)
|
||||
* - static `isInstance(x)`
|
||||
* - instance `toObject()` returning `{ name, data }`
|
||||
* - `new X({ ...data }, { cause })`
|
||||
*/
|
||||
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
|
||||
// Wire shape matches the original NamedError output so the SDK stays stable.
|
||||
const dataSchema = Schema.Struct(fields)
|
||||
const wire = z
|
||||
.object({
|
||||
name: z.literal(tag),
|
||||
data: zod(dataSchema),
|
||||
})
|
||||
.meta({ ref: tag })
|
||||
|
||||
// Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
|
||||
// Wire shape matches the original NamedError output so the SDK stays stable.
|
||||
const effectSchema = Schema.Struct({
|
||||
name: Schema.Literal(tag),
|
||||
data: dataSchema,
|
||||
@@ -35,7 +25,7 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
|
||||
type Data = Schema.Schema.Type<typeof dataSchema>
|
||||
|
||||
class NamedSchemaError extends Error {
|
||||
static readonly Schema = wire
|
||||
static readonly Schema = effectSchema
|
||||
static readonly EffectSchema = effectSchema
|
||||
static readonly tag = tag
|
||||
public static isInstance(input: unknown): input is NamedSchemaError {
|
||||
|
||||
Reference in New Issue
Block a user