refactor(core): move v1 schemas into core (#30473)
This commit is contained in:
@@ -21,6 +21,8 @@ import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
@@ -141,10 +143,21 @@ export const layer = Layer.effect(
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
|
||||
// Accept legacy fields while v2 is migrated incrementally; recognized
|
||||
// fields still have to satisfy the v2 schema.
|
||||
const decoded = ConfigMigrateV1.isV1(input)
|
||||
? Option.map(
|
||||
Schema.decodeUnknownOption(ConfigV1.Info)(input, {
|
||||
errors: "all",
|
||||
onExcessProperty: "ignore",
|
||||
propertyOrder: "original",
|
||||
}),
|
||||
ConfigMigrateV1.migrate,
|
||||
)
|
||||
: Option.some(input)
|
||||
const info = Option.getOrUndefined(
|
||||
Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }),
|
||||
Option.flatMap(
|
||||
decoded,
|
||||
Schema.decodeUnknownOption(Info, { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" }),
|
||||
),
|
||||
)
|
||||
if (!info) return
|
||||
return new Loaded({ source: { type: "file", path: filepath }, info })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionLegacy } from "./legacy"
|
||||
import { SessionV1 } from "../v1/session"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
@@ -27,7 +27,7 @@ type Usage = {
|
||||
}
|
||||
}
|
||||
|
||||
function usage(part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined {
|
||||
function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined {
|
||||
if (typeof part !== "object" || part === null) return undefined
|
||||
const value = part as Record<string, unknown>
|
||||
if (value.type !== "step-finish") return undefined
|
||||
@@ -35,7 +35,7 @@ function usage(part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part
|
||||
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
|
||||
}
|
||||
|
||||
function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$inferInsert {
|
||||
function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert {
|
||||
return {
|
||||
id: info.id,
|
||||
project_id: info.projectID,
|
||||
@@ -70,14 +70,14 @@ function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$infer
|
||||
}
|
||||
|
||||
function messageData(
|
||||
info: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["info"],
|
||||
info: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["info"],
|
||||
): typeof MessageTable.$inferInsert.data {
|
||||
const { id: _, sessionID: __, ...rest } = info
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function partData(
|
||||
part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"],
|
||||
part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"],
|
||||
): typeof PartTable.$inferInsert.data {
|
||||
const { id: _, messageID: __, sessionID: ___, ...rest } = part
|
||||
return rest as DeepMutable<typeof rest>
|
||||
@@ -85,7 +85,7 @@ function partData(
|
||||
|
||||
function applyUsage(
|
||||
db: DatabaseService,
|
||||
sessionID: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["sessionID"],
|
||||
sessionID: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["sessionID"],
|
||||
value: Usage,
|
||||
sign = 1,
|
||||
) {
|
||||
@@ -270,7 +270,7 @@ export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* events.project(SessionLegacy.Event.Created, (event) =>
|
||||
yield* events.project(SessionV1.Event.Created, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.insert(SessionTable).values(sessionRow(event.data.info)).run().pipe(Effect.orDie)
|
||||
if (event.data.info.workspaceID) {
|
||||
@@ -283,7 +283,7 @@ export const layer = Layer.effectDiscard(
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionLegacy.Event.Updated, (event) =>
|
||||
yield* events.project(SessionV1.Event.Updated, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set(sessionRow(event.data.info))
|
||||
@@ -291,10 +291,10 @@ export const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* events.project(SessionLegacy.Event.Deleted, (event) =>
|
||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* events.project(SessionLegacy.Event.MessageUpdated, (event) =>
|
||||
yield* events.project(SessionV1.Event.MessageUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const time_created = event.data.info.time.created
|
||||
const id = event.data.info.id
|
||||
@@ -308,7 +308,7 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionLegacy.Event.MessageRemoved, (event) =>
|
||||
yield* events.project(SessionV1.Event.MessageRemoved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
@@ -327,7 +327,7 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionLegacy.Event.PartRemoved, (event) =>
|
||||
yield* events.project(SessionV1.Event.PartRemoved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
@@ -344,7 +344,7 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionLegacy.Event.PartUpdated, (event) =>
|
||||
yield* events.project(SessionV1.Event.PartUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const id = event.data.part.id
|
||||
const messageID = event.data.part.messageID
|
||||
|
||||
@@ -3,16 +3,16 @@ import * as DatabasePath from "../database/path"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import type { SessionMessage } from "./message"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import { PermissionLegacy } from "../permission/legacy"
|
||||
import { PermissionV1 } from "../v1/permission"
|
||||
import { ProjectV2 } from "../project"
|
||||
import type { SessionSchema } from "./schema"
|
||||
import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy"
|
||||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||
type LegacyMessageData = Omit<LegacyMessageInfo, "id" | "sessionID">
|
||||
type LegacyPartData = Omit<LegacyMessagePart, "id" | "sessionID" | "messageID">
|
||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session",
|
||||
@@ -42,7 +42,7 @@ export const SessionTable = sqliteTable(
|
||||
tokens_cache_read: integer().notNull().default(0),
|
||||
tokens_cache_write: integer().notNull().default(0),
|
||||
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionLegacy.Ruleset>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
id: string
|
||||
@@ -69,7 +69,7 @@ export const MessageTable = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<LegacyMessageData>(),
|
||||
data: text({ mode: "json" }).notNull().$type<V1MessageData>(),
|
||||
},
|
||||
(table) => [index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id)],
|
||||
)
|
||||
@@ -84,7 +84,7 @@ export const PartTable = sqliteTable(
|
||||
.references(() => MessageTable.id, { onDelete: "cascade" }),
|
||||
session_id: text().$type<SessionSchema.ID>().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<LegacyPartData>(),
|
||||
data: text({ mode: "json" }).notNull().$type<V1PartData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("part_message_id_id_idx").on(table.message_id, table.id),
|
||||
|
||||
89
packages/core/src/v1/config/agent.ts
Normal file
89
packages/core/src/v1/config/agent.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export * as ConfigAgentV1 from "./agent"
|
||||
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { PositiveInt } from "../../schema"
|
||||
import { ConfigPermissionV1 } from "./permission"
|
||||
|
||||
const Color = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||
])
|
||||
|
||||
const AgentSchema = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
model: Schema.optional(Schema.String),
|
||||
variant: Schema.optional(Schema.String).annotate({
|
||||
description: "Default model variant for this agent (applies only when using the agent's configured model).",
|
||||
}),
|
||||
temperature: Schema.optional(Schema.Finite),
|
||||
top_p: Schema.optional(Schema.Finite),
|
||||
prompt: Schema.optional(Schema.String),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||
description: "@deprecated Use 'permission' field instead",
|
||||
}),
|
||||
disable: Schema.optional(Schema.Boolean),
|
||||
description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),
|
||||
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
|
||||
hidden: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
|
||||
}),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
color: Schema.optional(Color).annotate({
|
||||
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
|
||||
}),
|
||||
steps: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum number of agentic iterations before forcing text-only response",
|
||||
}),
|
||||
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
|
||||
permission: Schema.optional(ConfigPermissionV1.Info),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
|
||||
const KNOWN_KEYS = new Set([
|
||||
"name",
|
||||
"model",
|
||||
"variant",
|
||||
"prompt",
|
||||
"description",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"mode",
|
||||
"hidden",
|
||||
"color",
|
||||
"steps",
|
||||
"maxSteps",
|
||||
"options",
|
||||
"permission",
|
||||
"disable",
|
||||
"tools",
|
||||
])
|
||||
|
||||
const normalize = (agent: Schema.Schema.Type<typeof AgentSchema>): Schema.Schema.Type<typeof AgentSchema> => {
|
||||
const options: Record<string, unknown> = { ...agent.options }
|
||||
for (const [key, value] of Object.entries(agent)) {
|
||||
if (!KNOWN_KEYS.has(key)) options[key] = value
|
||||
}
|
||||
|
||||
const permission: ConfigPermissionV1.Info = {}
|
||||
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
||||
const action = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch") {
|
||||
permission.edit = action
|
||||
continue
|
||||
}
|
||||
permission[tool] = action
|
||||
}
|
||||
globalThis.Object.assign(permission, agent.permission)
|
||||
|
||||
const steps = agent.steps ?? agent.maxSteps
|
||||
return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }
|
||||
}
|
||||
|
||||
export const Info = AgentSchema.pipe(
|
||||
Schema.decodeTo(AgentSchema, {
|
||||
decode: SchemaGetter.transform(normalize),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
).annotate({ identifier: "AgentConfig" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
25
packages/core/src/v1/config/attachment.ts
Normal file
25
packages/core/src/v1/config/attachment.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export * as ConfigAttachmentV1 from "./attachment"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../../schema"
|
||||
|
||||
export const Image = Schema.Struct({
|
||||
auto_resize: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
|
||||
}),
|
||||
max_width: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
|
||||
}),
|
||||
max_height: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
|
||||
}),
|
||||
max_base64_bytes: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum base64 payload bytes for an image attachment (default: 5242880)",
|
||||
}),
|
||||
}).annotate({ identifier: "ImageAttachmentConfig" })
|
||||
export type Image = Schema.Schema.Type<typeof Image>
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
|
||||
}).annotate({ identifier: "AttachmentConfig" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
12
packages/core/src/v1/config/command.ts
Normal file
12
packages/core/src/v1/config/command.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * as ConfigCommandV1 from "./command"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
template: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Schema.String),
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
69
packages/core/src/v1/config/config.ts
Normal file
69
packages/core/src/v1/config/config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export * as ConfigV1 from "./config"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
|
||||
import { ConfigExperimental } from "../../config/experimental"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigAttachmentV1 } from "./attachment"
|
||||
import { ConfigCommandV1 } from "./command"
|
||||
import { ConfigFormatterV1 } from "./formatter"
|
||||
import { ConfigLayoutV1 } from "./layout"
|
||||
import { ConfigLSPV1 } from "./lsp"
|
||||
import { ConfigMCPV1 } from "./mcp"
|
||||
import { ConfigPermissionV1 } from "./permission"
|
||||
import { ConfigPluginV1 } from "./plugin"
|
||||
import { ConfigProviderV1 } from "./provider"
|
||||
import { ConfigReferenceV1 } from "./reference"
|
||||
import { ConfigServerV1 } from "./server"
|
||||
import { ConfigSkillsV1 } from "./skills"
|
||||
|
||||
export type Layout = ConfigLayoutV1.Layout
|
||||
|
||||
export const WellKnown = Schema.Struct({
|
||||
config: Schema.optional(Schema.Json),
|
||||
remote_config: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
||||
const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
|
||||
identifier: "LogLevel",
|
||||
description: "Log level",
|
||||
})
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation" }),
|
||||
shell: Schema.optional(Schema.String).annotate({ description: "Default shell to use for terminal and bash tool" }),
|
||||
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
|
||||
server: Schema.optional(ConfigServerV1.Server).annotate({ description: "Server configuration for opencode serve and web commands" }),
|
||||
command: Schema.optional(Schema.Record(Schema.String, ConfigCommandV1.Info)).annotate({ description: "Command configuration, see https://opencode.ai/docs/commands" }),
|
||||
skills: Schema.optional(ConfigSkillsV1.Info).annotate({ description: "Additional skill folder paths" }),
|
||||
reference: Schema.optional(ConfigReferenceV1.Info).annotate({ description: "Named git or local directory references that can be mentioned as @alias or @alias/path" }),
|
||||
watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })),
|
||||
snapshot: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true." }),
|
||||
plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPluginV1.Spec))),
|
||||
share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({ description: "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing" }),
|
||||
autoshare: Schema.optional(Schema.Boolean).annotate({ description: "@deprecated Use 'share' field instead. Share newly created sessions automatically" }),
|
||||
autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({ description: "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications" }),
|
||||
disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Disable providers that are loaded automatically" }),
|
||||
enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "When set, ONLY these providers will be enabled. All other providers will be ignored" }),
|
||||
model: Schema.optional(Schema.String).annotate({ description: "Model to use in the format of provider/model, eg anthropic/claude-2" }),
|
||||
small_model: Schema.optional(Schema.String).annotate({ description: "Small model to use for tasks like title generation in the format of provider/model" }),
|
||||
default_agent: Schema.optional(Schema.String).annotate({ description: "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid." }),
|
||||
username: Schema.optional(Schema.String).annotate({ description: "Custom username to display in conversations instead of system username" }),
|
||||
mode: Schema.optional(Schema.StructWithRest(Schema.Struct({ build: Schema.optional(ConfigAgentV1.Info), plan: Schema.optional(ConfigAgentV1.Info) }), [Schema.Record(Schema.String, ConfigAgentV1.Info)])).annotate({ description: "@deprecated Use `agent` field instead." }),
|
||||
agent: Schema.optional(Schema.StructWithRest(Schema.Struct({ plan: Schema.optional(ConfigAgentV1.Info), build: Schema.optional(ConfigAgentV1.Info), general: Schema.optional(ConfigAgentV1.Info), explore: Schema.optional(ConfigAgentV1.Info), title: Schema.optional(ConfigAgentV1.Info), summary: Schema.optional(ConfigAgentV1.Info), compaction: Schema.optional(ConfigAgentV1.Info) }), [Schema.Record(Schema.String, ConfigAgentV1.Info)])).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
|
||||
provider: Schema.optional(Schema.Record(Schema.String, ConfigProviderV1.Info)).annotate({ description: "Custom provider configurations and model overrides" }),
|
||||
mcp: Schema.optional(Schema.Record(Schema.String, Schema.Union([ConfigMCPV1.Info, Schema.Struct({ enabled: Schema.Boolean })]))).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
|
||||
formatter: Schema.optional(ConfigFormatterV1.Info).annotate({ description: "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." }),
|
||||
lsp: Schema.optional(ConfigLSPV1.Info).annotate({ description: "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." }),
|
||||
instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional instruction files or patterns to include" }),
|
||||
layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||
permission: Schema.optional(ConfigPermissionV1.Info),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior" }),
|
||||
enterprise: Schema.optional(Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }) })),
|
||||
tool_output: Schema.optional(Schema.Struct({ max_lines: Schema.optional(PositiveInt).annotate({ description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)" }), max_bytes: Schema.optional(PositiveInt).annotate({ description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)" }) })).annotate({ description: "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned." }),
|
||||
compaction: Schema.optional(Schema.Struct({ auto: Schema.optional(Schema.Boolean).annotate({ description: "Enable automatic compaction when context is full (default: true)" }), prune: Schema.optional(Schema.Boolean).annotate({ description: "Enable pruning of old tool outputs (default: true)" }), tail_turns: Schema.optional(NonNegativeInt).annotate({ description: "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)" }), preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ description: "Maximum number of tokens from recent turns to preserve verbatim after compaction" }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction." }) })),
|
||||
experimental: Schema.optional(Schema.Struct({ disable_paste_summary: Schema.optional(Schema.Boolean), batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), openTelemetry: Schema.optional(Schema.Boolean).annotate({ description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)" }), primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Tools that should only be available to primary agents." }), continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({ description: "Continue the agent loop when a tool call is denied" }), mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests" }), policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ description: "Policy statements applied to supported resources, such as provider access" }) })),
|
||||
}).annotate({ identifier: "Config" })
|
||||
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
16
packages/core/src/v1/config/console-state.ts
Normal file
16
packages/core/src/v1/config/console-state.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export * as ConfigConsoleStateV1 from "./console-state"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../../schema"
|
||||
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optional(Schema.String),
|
||||
switchableOrgCount: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export const emptyConsoleState: ConsoleState = ConsoleState.make({
|
||||
consoleManagedProviders: [],
|
||||
activeOrgName: undefined,
|
||||
switchableOrgCount: 0,
|
||||
})
|
||||
34
packages/core/src/v1/config/error.ts
Normal file
34
packages/core/src/v1/config/error.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export * as ConfigErrorV1 from "./error"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NamedError } from "../../util/error"
|
||||
|
||||
const Issue = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
message: Schema.String,
|
||||
path: Schema.Array(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
export const JsonError = NamedError.create("ConfigJsonError", {
|
||||
path: Schema.String,
|
||||
message: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const InvalidError = NamedError.create("ConfigInvalidError", {
|
||||
path: Schema.String,
|
||||
issues: Schema.optional(Schema.Array(Issue)),
|
||||
message: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const FrontmatterError = NamedError.create("ConfigFrontmatterError", {
|
||||
path: Schema.String,
|
||||
message: Schema.String,
|
||||
})
|
||||
|
||||
export const DirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", {
|
||||
path: Schema.String,
|
||||
dir: Schema.String,
|
||||
suggestion: Schema.String,
|
||||
})
|
||||
13
packages/core/src/v1/config/formatter.ts
Normal file
13
packages/core/src/v1/config/formatter.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * as ConfigFormatterV1 from "./formatter"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Entry = Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
})
|
||||
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
6
packages/core/src/v1/config/layout.ts
Normal file
6
packages/core/src/v1/config/layout.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * as ConfigLayoutV1 from "./layout"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Layout = Schema.Literals(["auto", "stretch"]).annotate({ identifier: "LayoutConfig" })
|
||||
export type Layout = Schema.Schema.Type<typeof Layout>
|
||||
80
packages/core/src/v1/config/lsp.ts
Normal file
80
packages/core/src/v1/config/lsp.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export * as ConfigLSPV1 from "./lsp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Disabled = Schema.Struct({
|
||||
disabled: Schema.Literal(true),
|
||||
}).pipe((schema) => schema)
|
||||
|
||||
export const Entry = Schema.Union([
|
||||
Disabled,
|
||||
Schema.Struct({
|
||||
command: Schema.mutable(Schema.Array(Schema.String)),
|
||||
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
]).pipe((schema) => schema)
|
||||
|
||||
// Keep this list aligned with the builtin servers in opencode's LSP runtime.
|
||||
// Custom servers must declare extensions because the runtime cannot infer them.
|
||||
export const builtinServerIds = [
|
||||
"deno",
|
||||
"typescript",
|
||||
"vue",
|
||||
"eslint",
|
||||
"oxlint",
|
||||
"biome",
|
||||
"gopls",
|
||||
"ruby-lsp",
|
||||
"ty",
|
||||
"pyright",
|
||||
"elixir-ls",
|
||||
"zls",
|
||||
"csharp",
|
||||
"razor",
|
||||
"fsharp",
|
||||
"sourcekit-lsp",
|
||||
"rust",
|
||||
"clangd",
|
||||
"svelte",
|
||||
"astro",
|
||||
"jdtls",
|
||||
"kotlin-ls",
|
||||
"yaml-ls",
|
||||
"lua-ls",
|
||||
"php intelephense",
|
||||
"prisma",
|
||||
"dart",
|
||||
"ocaml-lsp",
|
||||
"bash",
|
||||
"terraform",
|
||||
"texlab",
|
||||
"dockerfile",
|
||||
"gleam",
|
||||
"clojure-lsp",
|
||||
"nixd",
|
||||
"tinymist",
|
||||
"haskell-language-server",
|
||||
"julials",
|
||||
]
|
||||
|
||||
export const requiresExtensionsForCustomServers = Schema.makeFilter<
|
||||
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
|
||||
>((data) => {
|
||||
if (typeof data === "boolean") return undefined
|
||||
const ids = new Set(builtinServerIds)
|
||||
const ok = Object.entries(data).every(([id, config]) => {
|
||||
if ("disabled" in config && config.disabled) return true
|
||||
if (ids.has(id)) return true
|
||||
return "extensions" in config && Boolean(config.extensions)
|
||||
})
|
||||
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
|
||||
})
|
||||
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
.check(requiresExtensionsForCustomServers)
|
||||
.pipe((schema) => schema)
|
||||
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
60
packages/core/src/v1/config/mcp.ts
Normal file
60
packages/core/src/v1/config/mcp.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export * as ConfigMCPV1 from "./mcp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../../schema"
|
||||
|
||||
export const Local = Schema.Struct({
|
||||
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
|
||||
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
|
||||
description: "Command and arguments to run the MCP server",
|
||||
}),
|
||||
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
|
||||
description: "Environment variables to set when running the MCP server",
|
||||
}),
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable or disable the MCP server on startup",
|
||||
}),
|
||||
timeout: Schema.optional(PositiveInt).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
}).annotate({ identifier: "McpLocalConfig" })
|
||||
export type Local = Schema.Schema.Type<typeof Local>
|
||||
|
||||
export const OAuth = Schema.Struct({
|
||||
clientId: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
|
||||
}),
|
||||
clientSecret: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth client secret (if required by the authorization server)",
|
||||
}),
|
||||
scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
|
||||
callbackPort: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))).annotate({
|
||||
description:
|
||||
"Port for the local OAuth callback server (default: 19876). Shorthand for redirectUri when only the port needs changing. Ignored if redirectUri is set.",
|
||||
}),
|
||||
redirectUri: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
|
||||
}),
|
||||
}).annotate({ identifier: "McpOAuthConfig" })
|
||||
export type OAuth = Schema.Schema.Type<typeof OAuth>
|
||||
|
||||
export const Remote = Schema.Struct({
|
||||
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
|
||||
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable or disable the MCP server on startup",
|
||||
}),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
|
||||
description: "Headers to send with the request",
|
||||
}),
|
||||
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
|
||||
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
|
||||
}),
|
||||
timeout: Schema.optional(PositiveInt).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
}).annotate({ identifier: "McpRemoteConfig" })
|
||||
export type Remote = Schema.Schema.Type<typeof Remote>
|
||||
|
||||
export const Info = Schema.Union([Local, Remote]).annotate({ discriminator: "type" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
212
packages/core/src/v1/config/migrate.ts
Normal file
212
packages/core/src/v1/config/migrate.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
export * as ConfigMigrateV1 from "./migrate"
|
||||
|
||||
import { ConfigV1 } from "./config"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigMCPV1 } from "./mcp"
|
||||
import { ConfigPermissionV1 } from "./permission"
|
||||
import { ConfigProviderV1 } from "./provider"
|
||||
|
||||
const keys = new Set([
|
||||
"logLevel",
|
||||
"server",
|
||||
"command",
|
||||
"reference",
|
||||
"snapshot",
|
||||
"plugin",
|
||||
"autoshare",
|
||||
"disabled_providers",
|
||||
"enabled_providers",
|
||||
"small_model",
|
||||
"default_agent",
|
||||
"mode",
|
||||
"agent",
|
||||
"provider",
|
||||
"permission",
|
||||
"tools",
|
||||
"attachment",
|
||||
"layout",
|
||||
])
|
||||
|
||||
export function isV1(input: unknown) {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
|
||||
return Object.keys(input).some((key) => keys.has(key))
|
||||
}
|
||||
|
||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
return {
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: info.model,
|
||||
autoupdate: info.autoupdate,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
permissions: permissions(info.permission, info.tools),
|
||||
agents: agents(info),
|
||||
snapshots: info.snapshot,
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
attachments: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
auto: info.compaction.auto,
|
||||
prune: info.compaction.prune,
|
||||
keep: {
|
||||
turns: info.compaction.tail_turns,
|
||||
tokens: info.compaction.preserve_recent_tokens,
|
||||
},
|
||||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
instructions: info.instructions,
|
||||
references: info.reference,
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
),
|
||||
experimental: info.experimental?.policies && { policies: info.experimental.policies },
|
||||
providers: providers(info.provider),
|
||||
}
|
||||
}
|
||||
|
||||
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
|
||||
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
|
||||
tools ?? {},
|
||||
).map(([action, enabled]) => ({
|
||||
action: normalizeAction(action),
|
||||
resource: "*",
|
||||
effect: enabled ? ("allow" as const) : ("deny" as const),
|
||||
}))
|
||||
for (const [action, rule] of Object.entries(info ?? {})) {
|
||||
if (!rule) continue
|
||||
if (typeof rule === "string") {
|
||||
rules.push({ action, resource: "*", effect: rule })
|
||||
continue
|
||||
}
|
||||
rules.push(...Object.entries(rule).map(([resource, effect]) => ({ action, resource, effect })))
|
||||
}
|
||||
return rules.length ? rules : undefined
|
||||
}
|
||||
|
||||
function normalizeAction(action: string) {
|
||||
return action === "write" || action === "patch" ? "edit" : action
|
||||
}
|
||||
|
||||
function agents(info: typeof ConfigV1.Info.Type) {
|
||||
const entries = [
|
||||
...Object.entries(info.agent ?? {}),
|
||||
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
||||
]
|
||||
if (!entries.length) return undefined
|
||||
return Object.fromEntries(entries.map(([name, agent]) => [name, migrateAgent(agent)]))
|
||||
}
|
||||
|
||||
function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
const body = {
|
||||
...info.options,
|
||||
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
||||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||
}
|
||||
return {
|
||||
model: info.model,
|
||||
variant: info.variant,
|
||||
options: Object.keys(body).length ? { body } : undefined,
|
||||
system: info.prompt,
|
||||
description: info.description,
|
||||
mode: info.mode,
|
||||
hidden: info.hidden,
|
||||
color: info.color,
|
||||
steps: info.steps,
|
||||
disabled: info.disable,
|
||||
permissions: permissions(info.permission),
|
||||
}
|
||||
}
|
||||
|
||||
function mcp(info: typeof ConfigV1.Info.Type) {
|
||||
const servers = Object.fromEntries(
|
||||
Object.entries(info.mcp ?? {}).flatMap(([name, server]) =>
|
||||
"type" in server ? [[name, migrateMcp(server)] as const] : [],
|
||||
),
|
||||
)
|
||||
const timeout = info.experimental?.mcp_timeout
|
||||
if (!timeout && !Object.keys(servers).length) return undefined
|
||||
return { timeout, servers }
|
||||
}
|
||||
|
||||
function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
||||
if (info.type === "local") return { type: info.type, command: info.command, environment: info.environment, disabled, timeout: info.timeout }
|
||||
return {
|
||||
type: info.type,
|
||||
url: info.url,
|
||||
headers: info.headers,
|
||||
oauth:
|
||||
info.oauth && {
|
||||
client_id: info.oauth.clientId,
|
||||
client_secret: info.oauth.clientSecret,
|
||||
scope: info.oauth.scope,
|
||||
callback_port: info.oauth.callbackPort,
|
||||
redirect_uri: info.oauth.redirectUri,
|
||||
},
|
||||
disabled,
|
||||
timeout: info.timeout,
|
||||
}
|
||||
}
|
||||
|
||||
function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(Object.entries(info).map(([name, provider]) => [name, migrateProvider(provider)]))
|
||||
}
|
||||
|
||||
function migrateProvider(info: ConfigProviderV1.Info) {
|
||||
return {
|
||||
name: info.name,
|
||||
env: info.env,
|
||||
endpoint: info.npm && {
|
||||
type: "aisdk" as const,
|
||||
package: info.npm,
|
||||
url: info.api ?? (typeof info.options?.baseURL === "string" ? info.options.baseURL : undefined),
|
||||
},
|
||||
options: info.options && { body: info.options },
|
||||
models: info.models && Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)])),
|
||||
}
|
||||
}
|
||||
|
||||
function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
|
||||
const costs = info.cost && [
|
||||
{
|
||||
input: info.cost.input,
|
||||
output: info.cost.output,
|
||||
cache: { read: info.cost.cache_read, write: info.cost.cache_write },
|
||||
},
|
||||
...(info.cost.context_over_200k
|
||||
? [
|
||||
{
|
||||
tier: { type: "context" as const, size: 200_000 },
|
||||
input: info.cost.context_over_200k.input,
|
||||
output: info.cost.context_over_200k.output,
|
||||
cache: { read: info.cost.context_over_200k.cache_read, write: info.cost.context_over_200k.cache_write },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const capabilities =
|
||||
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
|
||||
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
|
||||
: undefined
|
||||
return {
|
||||
api_id: info.id,
|
||||
family: info.family,
|
||||
name: info.name,
|
||||
endpoint: info.provider?.npm && { type: "aisdk" as const, package: info.provider.npm, url: info.provider.api },
|
||||
capabilities,
|
||||
options: (info.headers || info.options) && { headers: info.headers, body: info.options },
|
||||
variants:
|
||||
info.variants &&
|
||||
Object.entries(info.variants).map(([id, options]) => ({ id, body: options })),
|
||||
cost: costs,
|
||||
disabled: info.status === "deprecated" ? true : undefined,
|
||||
limit: info.limit,
|
||||
}
|
||||
}
|
||||
50
packages/core/src/v1/config/permission.ts
Normal file
50
packages/core/src/v1/config/permission.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export * as ConfigPermissionV1 from "./permission"
|
||||
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
|
||||
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
|
||||
export type Action = Schema.Schema.Type<typeof Action>
|
||||
|
||||
export const Object = Schema.Record(Schema.String, Action).annotate({ identifier: "PermissionObjectConfig" })
|
||||
export type Object = Schema.Schema.Type<typeof Object>
|
||||
|
||||
export const Rule = Schema.Union([Action, Object]).annotate({ identifier: "PermissionRuleConfig" })
|
||||
export type Rule = Schema.Schema.Type<typeof Rule>
|
||||
|
||||
// Known permission keys get explicit types in the Effect schema for generated
|
||||
// docs/types. Runtime config parsing uses Effect's `propertyOrder: "original"`
|
||||
// parse option so user key order is preserved for permission precedence.
|
||||
const InputObject = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
read: Schema.optional(Rule),
|
||||
edit: Schema.optional(Rule),
|
||||
glob: Schema.optional(Rule),
|
||||
grep: Schema.optional(Rule),
|
||||
list: Schema.optional(Rule),
|
||||
bash: Schema.optional(Rule),
|
||||
task: Schema.optional(Rule),
|
||||
external_directory: Schema.optional(Rule),
|
||||
todowrite: Schema.optional(Action),
|
||||
question: Schema.optional(Action),
|
||||
webfetch: Schema.optional(Action),
|
||||
websearch: Schema.optional(Action),
|
||||
lsp: Schema.optional(Rule),
|
||||
doom_loop: Schema.optional(Action),
|
||||
skill: Schema.optional(Rule),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Rule)],
|
||||
)
|
||||
|
||||
const InputSchema = Schema.Union([Action, InputObject])
|
||||
|
||||
const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>
|
||||
typeof input === "string" ? { "*": input } : input
|
||||
|
||||
export const Info = InputSchema.pipe(
|
||||
Schema.decodeTo(InputObject, {
|
||||
decode: SchemaGetter.transform(normalizeInput),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
).annotate({ identifier: "PermissionConfig" })
|
||||
type _Info = Schema.Schema.Type<typeof InputObject>
|
||||
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
|
||||
9
packages/core/src/v1/config/plugin.ts
Normal file
9
packages/core/src/v1/config/plugin.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * as ConfigPluginV1 from "./plugin"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Options = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export type Options = Schema.Schema.Type<typeof Options>
|
||||
|
||||
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))])
|
||||
export type Spec = Schema.Schema.Type<typeof Spec>
|
||||
119
packages/core/src/v1/config/provider.ts
Normal file
119
packages/core/src/v1/config/provider.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export * as ConfigProviderV1 from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../../schema"
|
||||
|
||||
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
family: Schema.optional(Schema.String),
|
||||
release_date: Schema.optional(Schema.String),
|
||||
attachment: Schema.optional(Schema.Boolean),
|
||||
reasoning: Schema.optional(Schema.Boolean),
|
||||
temperature: Schema.optional(Schema.Boolean),
|
||||
tool_call: Schema.optional(Schema.Boolean),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal(true),
|
||||
Schema.Struct({
|
||||
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
cost: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
context_over_200k: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
limit: Schema.optional(
|
||||
Schema.Struct({
|
||||
context: Schema.Finite,
|
||||
input: Schema.optional(Schema.Finite),
|
||||
output: Schema.Finite,
|
||||
}),
|
||||
),
|
||||
modalities: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.optional(Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])))),
|
||||
output: Schema.optional(
|
||||
Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
|
||||
),
|
||||
}),
|
||||
),
|
||||
experimental: Schema.optional(Schema.Boolean),
|
||||
status: Schema.optional(ModelStatus),
|
||||
provider: Schema.optional(Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) })),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
variants: Schema.optional(
|
||||
Schema.Record(
|
||||
Schema.String,
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
),
|
||||
).annotate({ description: "Variant-specific configuration" }),
|
||||
),
|
||||
})
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
api: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
id: Schema.optional(Schema.String),
|
||||
npm: Schema.optional(Schema.String),
|
||||
whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
options: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
apiKey: Schema.optional(Schema.String),
|
||||
baseURL: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String).annotate({
|
||||
description: "GitHub Enterprise URL for copilot authentication",
|
||||
}),
|
||||
setCacheKey: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable promptCacheKey for this provider (default false)",
|
||||
}),
|
||||
timeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
|
||||
}),
|
||||
headerTimeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
|
||||
}),
|
||||
chunkTimeout: Schema.optional(PositiveInt).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
|
||||
}),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
),
|
||||
),
|
||||
models: Schema.optional(Schema.Record(Schema.String, Model)),
|
||||
}).annotate({ identifier: "ProviderConfig" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
24
packages/core/src/v1/config/reference.ts
Normal file
24
packages/core/src/v1/config/reference.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export * as ConfigReferenceV1 from "./reference"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
const Git = Schema.Struct({
|
||||
repository: Schema.String.annotate({
|
||||
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}),
|
||||
branch: Schema.optional(Schema.String).annotate({
|
||||
description: "Branch or ref to clone and inspect",
|
||||
}),
|
||||
})
|
||||
|
||||
const Local = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory",
|
||||
}),
|
||||
})
|
||||
|
||||
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
|
||||
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||
|
||||
export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
19
packages/core/src/v1/config/server.ts
Normal file
19
packages/core/src/v1/config/server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export * as ConfigServerV1 from "./server"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../../schema"
|
||||
|
||||
export const Server = Schema.Struct({
|
||||
port: Schema.optional(PositiveInt).annotate({
|
||||
description: "Port to listen on",
|
||||
}),
|
||||
hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }),
|
||||
mdns: Schema.optional(Schema.Boolean).annotate({ description: "Enable mDNS service discovery" }),
|
||||
mdnsDomain: Schema.optional(Schema.String).annotate({
|
||||
description: "Custom domain name for mDNS service (default: opencode.local)",
|
||||
}),
|
||||
cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
||||
description: "Additional domains to allow for CORS",
|
||||
}),
|
||||
}).annotate({ identifier: "ServerConfig" })
|
||||
export type Server = Schema.Schema.Type<typeof Server>
|
||||
13
packages/core/src/v1/config/skills.ts
Normal file
13
packages/core/src/v1/config/skills.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * as ConfigSkillsV1 from "./skills"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
|
||||
description: "Additional paths to skill folders",
|
||||
}),
|
||||
urls: Schema.optional(Schema.Array(Schema.String)).annotate({
|
||||
description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
|
||||
}),
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as PermissionLegacy from "./legacy"
|
||||
export * as PermissionV1 from "./permission"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ProjectV2 } from "../project"
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as SessionLegacy from "./legacy"
|
||||
export * as SessionV1 from "./session"
|
||||
|
||||
import { Effect, Schema, Types } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { PermissionLegacy } from "../permission/legacy"
|
||||
import { PermissionV1 } from "./permission"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { optionalOmitUndefined, withStatics } from "../schema"
|
||||
import { Identifier } from "../util/identifier"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { NamedError } from "../util/error"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
|
||||
@@ -558,7 +558,7 @@ export const SessionInfo = Schema.Struct({
|
||||
compacting: optionalOmitUndefined(NonNegativeInt),
|
||||
archived: optionalOmitUndefined(Schema.Finite),
|
||||
}),
|
||||
permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
|
||||
permission: optionalOmitUndefined(PermissionV1.Ruleset),
|
||||
revert: optionalOmitUndefined(SessionRevert),
|
||||
}).annotate({ identifier: "Session" })
|
||||
export type SessionInfo = typeof SessionInfo.Type
|
||||
@@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -53,6 +54,14 @@ const provider = {
|
||||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns an empty configuration when directory files do not exist", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -337,6 +346,100 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("migrates v1 configuration when a v1-only key is present", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
shell: "/bin/zsh",
|
||||
snapshot: false,
|
||||
autoshare: true,
|
||||
permission: {
|
||||
bash: "ask",
|
||||
edit: { "*.md": "allow", "*": "deny" },
|
||||
},
|
||||
agent: {
|
||||
reviewer: {
|
||||
prompt: "Review changes.",
|
||||
disable: true,
|
||||
temperature: 0.2,
|
||||
permission: { read: "allow" },
|
||||
},
|
||||
},
|
||||
plugin: ["opencode-helicone-session", ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }]],
|
||||
skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
|
||||
reference: { docs: { path: "../docs" } },
|
||||
attachment: { image: { auto_resize: false, max_width: 1200 } },
|
||||
compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
|
||||
experimental: { mcp_timeout: 5000 },
|
||||
mcp: {
|
||||
local: { type: "local", command: ["node", "server.js"], enabled: false },
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com",
|
||||
oauth: { clientId: "client", callbackPort: 19876 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
|
||||
expect(documents).toHaveLength(1)
|
||||
expect(documents[0]?.info).toBeInstanceOf(Config.Info)
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
expect(documents[0]?.info.snapshots).toBe(false)
|
||||
expect(documents[0]?.info.share).toBe("auto")
|
||||
expect(documents[0]?.info.permissions).toEqual([
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*.md", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
])
|
||||
expect(documents[0]?.info.agents?.reviewer).toMatchObject({
|
||||
system: "Review changes.",
|
||||
disabled: true,
|
||||
options: { body: { temperature: 0.2 } },
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
})
|
||||
expect(documents[0]?.info.plugins).toEqual([
|
||||
"opencode-helicone-session",
|
||||
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
|
||||
])
|
||||
expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
|
||||
expect(documents[0]?.info.references).toEqual({ docs: { path: "../docs" } })
|
||||
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.compaction).toEqual({
|
||||
auto: true,
|
||||
prune: undefined,
|
||||
keep: { turns: 3, tokens: 2000 },
|
||||
buffer: 10000,
|
||||
})
|
||||
expect(documents[0]?.info.mcp).toMatchObject({
|
||||
timeout: 5000,
|
||||
servers: {
|
||||
local: { type: "local", command: ["node", "server.js"], disabled: true },
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com",
|
||||
oauth: { client_id: "client", callback_port: 19876 },
|
||||
},
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores invalid files while loading valid config values", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
Reference in New Issue
Block a user