mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7374576ee6 |
@@ -0,0 +1,55 @@
|
||||
export * as ConfigFormatterPlugin from "./formatter.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.formatter",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(formatter.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* formatter.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "formatter")
|
||||
if (configured === false) {
|
||||
draft.clear()
|
||||
return
|
||||
}
|
||||
if (configured === undefined) {
|
||||
for (const item of draft.list()) if (item.builtIn) draft.remove(item.name)
|
||||
return
|
||||
}
|
||||
if (configured === true) return
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
if (entry.disabled) {
|
||||
draft.remove(name)
|
||||
continue
|
||||
}
|
||||
const current = draft.get(name)
|
||||
draft.set(name, {
|
||||
name,
|
||||
extensions: entry.extensions ?? current?.extensions ?? [],
|
||||
environment: { ...current?.environment, ...entry.environment },
|
||||
enabled:
|
||||
current && !entry.command ? current.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export * as ConfigImagePlugin from "./image.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Image } from "../../image.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.image",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const image = yield* Image.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(image.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* image.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.media?.image) continue
|
||||
draft.update((policy) => {
|
||||
const configured = entry.info.media?.image
|
||||
if (!configured) return
|
||||
if (configured.auto_resize !== undefined) policy.autoResize = configured.auto_resize
|
||||
if (configured.max_width !== undefined) policy.maxWidth = configured.max_width
|
||||
if (configured.max_height !== undefined) policy.maxHeight = configured.max_height
|
||||
if (configured.max_base64_bytes !== undefined) policy.maxBase64Bytes = configured.max_base64_bytes
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export * as ConfigToolOutputPlugin from "./tool-output.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.tool-output",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const output = yield* ToolOutput.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(output.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* output.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "tool_output")
|
||||
if (!configured) return
|
||||
draft.update((policy) => {
|
||||
if (configured.max_lines !== undefined) policy.maxLines = configured.max_lines
|
||||
if (configured.max_bytes !== undefined) policy.maxBytes = configured.max_bytes
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -8,11 +8,23 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config.js"
|
||||
import { Location } from "./location.js"
|
||||
import { make, type Info } from "./formatter/builtins.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export interface Interface {
|
||||
type Data = {
|
||||
readonly formatters: Map<string, Info>
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly list: () => readonly Info[]
|
||||
readonly get: (name: string) => Info | undefined
|
||||
readonly set: (name: string, formatter: Info) => void
|
||||
readonly remove: (name: string) => void
|
||||
readonly clear: () => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
@@ -21,66 +33,48 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const global = yield* Global.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
const load = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
||||
if (!configured) {
|
||||
yield* Effect.logInfo("all formatters are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
if (entry.disabled) {
|
||||
if (index !== -1) formatters.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
const formatter: Info = {
|
||||
name,
|
||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||
environment: { ...builtIn?.environment, ...entry.environment },
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
if (index === -1) formatters.push(formatter)
|
||||
else formatters[index] = formatter
|
||||
}
|
||||
}).pipe(Effect.withSpan("Formatter.load")),
|
||||
)
|
||||
const commands = new WeakMap<Info, string[] | false>()
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "formatter",
|
||||
initial: () => ({
|
||||
formatters: new Map(builtIns.map((formatter) => [formatter.name, { ...formatter, builtIn: true }])),
|
||||
}),
|
||||
draft: (data) => ({
|
||||
list: () => Array.from(data.formatters.values()),
|
||||
get: (name) => data.formatters.get(name),
|
||||
set: (name, formatter) => data.formatters.set(name, { ...formatter, name }),
|
||||
remove: (name) => {
|
||||
data.formatters.delete(name)
|
||||
},
|
||||
clear: () => data.formatters.clear(),
|
||||
}),
|
||||
})
|
||||
|
||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||
const cached = commands.get(formatter.name)
|
||||
const cached = commands.get(formatter)
|
||||
if (cached !== undefined) return cached
|
||||
const result = yield* formatter.enabled
|
||||
if (result !== false) commands.set(formatter.name, result)
|
||||
if (result !== false) commands.set(formatter, result)
|
||||
return result
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
const matching = Array.from(state.get().formatters.values()).filter((formatter) =>
|
||||
formatter.extensions.includes(path.extname(filepath)),
|
||||
)
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
@@ -118,12 +112,12 @@ const layer = Layer.effect(
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ file })
|
||||
return Service.of({ file, transform: state.transform, reload: state.reload })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
deps: [FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { which } from "../util/which.js"
|
||||
|
||||
export interface Info {
|
||||
readonly name: string
|
||||
readonly builtIn?: boolean
|
||||
readonly environment?: Record<string, string>
|
||||
readonly extensions: readonly string[]
|
||||
readonly enabled: Effect.Effect<string[] | false>
|
||||
|
||||
+33
-15
@@ -2,8 +2,9 @@ export * as Image from "./image.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import type { DeepMutable } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()(
|
||||
"Image.ResizerUnavailableError",
|
||||
@@ -32,7 +33,18 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
export interface Policy {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly update: (update: (policy: DeepMutable<Policy>) => void) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
@@ -47,7 +59,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const state = State.create<DeepMutable<Policy>, Draft>({
|
||||
name: "image",
|
||||
initial: () => ({
|
||||
autoResize: true,
|
||||
maxWidth: 2_000,
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
draft: (policy) => ({
|
||||
update: (update) => update(policy),
|
||||
}),
|
||||
})
|
||||
const loadAdapter = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("./image/photon.js"),
|
||||
@@ -58,22 +81,17 @@ const layer = Layer.effect(
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
) {
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||
),
|
||||
)
|
||||
const policy = state.get()
|
||||
const normalize = yield* loadAdapter
|
||||
return yield* normalize(resource, content, {
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? 2_000,
|
||||
maxHeight: image.max_height ?? 2_000,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
|
||||
autoResize: policy.autoResize,
|
||||
maxWidth: policy.maxWidth,
|
||||
maxHeight: policy.maxHeight,
|
||||
maxBase64Bytes: policy.maxBase64Bytes,
|
||||
})
|
||||
})
|
||||
return Service.of({ normalize })
|
||||
return Service.of({ normalize, transform: state.transform, reload: state.reload })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Formatter } from "./formatter.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
@@ -154,6 +155,7 @@ export const node = makeLocationNode({
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Formatter.node,
|
||||
Integration.node,
|
||||
MCP.node,
|
||||
Location.node,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AISDK } from "../aisdk.js"
|
||||
import { Catalog } from "../catalog.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Formatter } from "../formatter.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { Location } from "../location.js"
|
||||
@@ -36,6 +37,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integration = yield* Integration.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
@@ -185,6 +187,22 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
},
|
||||
formatter: {
|
||||
reload: formatter.reload,
|
||||
transform: (callback) =>
|
||||
formatter.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.set(definition.name, {
|
||||
name: definition.name,
|
||||
extensions: [...definition.extensions],
|
||||
environment: definition.environment === undefined ? undefined : { ...definition.environment },
|
||||
enabled: Effect.succeed([...definition.command]),
|
||||
}),
|
||||
remove: draft.remove,
|
||||
})
|
||||
}),
|
||||
},
|
||||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
@@ -20,6 +22,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference.js"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileMutation } from "../file-mutation.js"
|
||||
@@ -57,6 +60,7 @@ import { ShellTool } from "../tool/plugin/shell.js"
|
||||
import { SkillTool } from "../tool/plugin/skill.js"
|
||||
import { SubagentTool } from "../tool/plugin/subagent.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { ToolOutput } from "../tool-output.js"
|
||||
import { WebFetchTool } from "../tool/plugin/webfetch.js"
|
||||
import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
@@ -111,6 +115,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skillDiscovery = yield* SkillDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
@@ -149,6 +154,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Skill.Service, skill),
|
||||
Context.make(SkillDiscovery.Service, skillDiscovery),
|
||||
Context.make(Tool.Service, tools),
|
||||
Context.make(ToolOutput.Service, toolOutput),
|
||||
Context.make(Watcher.Service, watcher),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
@@ -194,6 +200,7 @@ export const requirements = LayerNode.group([
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
])
|
||||
@@ -232,6 +239,9 @@ const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigFormatterPlugin.Plugin,
|
||||
ConfigImagePlugin.Plugin,
|
||||
ConfigToolOutputPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config.js"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import type { DeepMutable } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
@@ -16,7 +17,16 @@ export const DIRECTORY = "tool-output"
|
||||
|
||||
type Result = Tool.Result
|
||||
|
||||
export interface Interface {
|
||||
export interface Policy {
|
||||
readonly maxLines: number
|
||||
readonly maxBytes: number
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly update: (update: (policy: DeepMutable<Policy>) => void) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly truncate: (result: Result) => Effect.Effect<Result>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -46,19 +56,23 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
const state = State.create<DeepMutable<Policy>, Draft>({
|
||||
name: "tool-output",
|
||||
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
|
||||
draft: (policy) => ({
|
||||
update: (update) => update(policy),
|
||||
}),
|
||||
})
|
||||
|
||||
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? MAX_BYTES
|
||||
const { maxLines, maxBytes } = state.get()
|
||||
const lines = text.split("\n")
|
||||
if (text.endsWith("\n")) lines.pop()
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
@@ -113,7 +127,12 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
|
||||
return Service.of({
|
||||
truncate,
|
||||
cleanup: () => cleanup(fs, directory),
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -137,5 +156,5 @@ const cleanupNode = makeGlobalNode({
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
|
||||
deps: [FSUtil.node, Global.node, cleanupNode],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { host } from "../plugin/host"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("ConfigImagePlugin.Plugin", () => {
|
||||
it.live("materializes image policy from config", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = {
|
||||
autoResize: true,
|
||||
maxWidth: 2_000,
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}
|
||||
const image = Image.Service.of({
|
||||
normalize: () => Effect.die("unused image.normalize"),
|
||||
reload: () => Effect.void,
|
||||
transform: (callback) =>
|
||||
Effect.sync(() => {
|
||||
callback({ update: (update) => update(policy) })
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
})
|
||||
yield* ConfigImagePlugin.Plugin.effect(host()).pipe(
|
||||
Effect.provideService(Image.Service, image),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({
|
||||
auto_resize: false,
|
||||
max_width: 1_000,
|
||||
max_height: 800,
|
||||
max_base64_bytes: 123_456,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(policy).toEqual({
|
||||
autoResize: false,
|
||||
maxWidth: 1_000,
|
||||
maxHeight: 800,
|
||||
maxBase64Bytes: 123_456,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -7,33 +7,32 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Config } from "../src/config"
|
||||
import { ConfigFormatterPlugin } from "../src/config/plugin/formatter"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
[Config.node, Config.testLayer(entries)],
|
||||
const layer = AppNodeBuilder.build(Formatter.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
||||
])
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [new Document({ type: "document", info: Schema.decodeUnknownSync(Info)({ formatter: configured }) })]
|
||||
return Layer.effectDiscard(ConfigFormatterPlugin.Plugin.effect(host()).pipe(Effect.provide(Config.testLayer(entries)))).pipe(
|
||||
Layer.provideMerge(layer),
|
||||
)
|
||||
}
|
||||
|
||||
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
@@ -162,4 +161,34 @@ describe("Formatter", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves a replacement formatter command independently", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const formatter = yield* Formatter.Service
|
||||
const file = path.join(directory, "test.replaced")
|
||||
const register = (content: string) =>
|
||||
formatter.transform((draft) => {
|
||||
draft.set("replacement", {
|
||||
name: "replacement",
|
||||
extensions: [".replaced"],
|
||||
enabled: Effect.succeed([
|
||||
process.execPath,
|
||||
"-e",
|
||||
`require('fs').writeFileSync(process.argv.at(-1), '${content}')`,
|
||||
"$FILE",
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
yield* register("first")
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("first")
|
||||
|
||||
yield* register("second")
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("second")
|
||||
}).pipe(Effect.provide(formatterLayer(directory, false))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,4 +4,6 @@ import { Effect, Layer } from "effect"
|
||||
/** Passthrough resizer for tests that build Tool.node without a Location. */
|
||||
export const imagePassthrough = Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) => Effect.succeed(content),
|
||||
transform: () => Effect.die("unused image.transform"),
|
||||
reload: () => Effect.die("unused image.reload"),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -16,6 +17,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
@@ -99,6 +102,34 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers formatters through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const file = path.join(location.directory, "plugin.formatter-test")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "before"))
|
||||
const registration = yield* host.formatter.transform((draft) => {
|
||||
draft.add({
|
||||
name: "plugin",
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); fs.writeFileSync(process.argv.at(-1), 'after')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".formatter-test"],
|
||||
})
|
||||
})
|
||||
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("after")
|
||||
yield* registration.dispose
|
||||
expect(yield* formatter.file(file)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces plugins by ID and version", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
@@ -44,6 +45,7 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Form.node,
|
||||
Formatter.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
Plugin.node,
|
||||
Agent.node,
|
||||
|
||||
@@ -48,6 +48,10 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
formatter: overrides.formatter ?? {
|
||||
transform: () => Effect.die("unused formatter.transform"),
|
||||
reload: () => Effect.die("unused formatter.reload"),
|
||||
},
|
||||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -12,6 +13,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Identifier } from "@opencode-ai/core/id/id"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const withStore = <A, E, R>(
|
||||
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
|
||||
@@ -21,10 +23,12 @@ const withStore = <A, E, R>(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const config = Config.testLayer([new Document({ type: "document", info })])
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Config.node, config],
|
||||
const base = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
])
|
||||
const layer = Layer.effectDiscard(
|
||||
ConfigToolOutputPlugin.Plugin.effect(host()).pipe(Effect.provide(config)),
|
||||
).pipe(Layer.provideMerge(base))
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -25,6 +26,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
name: "test/read-tool-plugin",
|
||||
@@ -90,7 +92,8 @@ const permission = permissionLayer({
|
||||
),
|
||||
})
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const imageLayer = AppNodeBuilder.build(Image.node)
|
||||
const configureImage = ConfigImagePlugin.Plugin.effect(host())
|
||||
const testFileSystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.use((fs) =>
|
||||
@@ -132,11 +135,15 @@ const mutation = Layer.succeed(
|
||||
)
|
||||
const unavailableImage = Layer.succeed(
|
||||
Image.Service,
|
||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
Image.Service.of({
|
||||
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
|
||||
transform: () => Effect.die("unused image.transform"),
|
||||
reload: () => Effect.die("unused image.reload"),
|
||||
}),
|
||||
)
|
||||
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode, Image.node]), [
|
||||
[ReadToolFileSystem.node, reader],
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
@@ -395,6 +402,7 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
@@ -436,6 +444,7 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
@@ -477,6 +486,7 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface FormatterDefinition {
|
||||
readonly name: string
|
||||
readonly command: readonly string[]
|
||||
readonly extensions: readonly string[]
|
||||
readonly environment?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface FormatterDraft {
|
||||
readonly add: (formatter: FormatterDefinition) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface FormatterDomain {
|
||||
readonly transform: Transform<FormatterDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { AISDKDomain } from "./aisdk.js"
|
||||
import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { FormatterDomain } from "./formatter.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
@@ -24,6 +25,7 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly formatter: FormatterDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
|
||||
@@ -158,6 +158,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
),
|
||||
},
|
||||
formatter: {
|
||||
transform: transform(host.formatter),
|
||||
reload: () => run(host.formatter.reload()),
|
||||
},
|
||||
integration: {
|
||||
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
|
||||
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface FormatterDefinition {
|
||||
readonly name: string
|
||||
readonly command: readonly string[]
|
||||
readonly extensions: readonly string[]
|
||||
readonly environment?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface FormatterDraft {
|
||||
readonly add: (formatter: FormatterDefinition) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface FormatterDomain {
|
||||
readonly transform: Transform<FormatterDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { AISDKDomain } from "./aisdk.js"
|
||||
import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { FormatterDomain } from "./formatter.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
@@ -23,6 +24,7 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly formatter: FormatterDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly plugin: PluginApi
|
||||
|
||||
Reference in New Issue
Block a user