diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 009d9d0605..68f64e52ee 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -4,7 +4,7 @@ export { Event, ID, Info } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { App } from "./app" -import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" +import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect" import { Agent } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" @@ -44,7 +44,12 @@ const layer = Layer.effect( const inherit = yield* State.inherit() const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe( inherit, - Effect.updateContext((_context: Context.Context) => Context.make(Scope.Scope, child)), + Effect.updateContext((context: Context.Context) => + Context.make(Scope.Scope, child).pipe( + Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)), + Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)), + ), + ), Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }), Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index a349afb774..c991008517 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -13,6 +13,19 @@ const session = yield * opencode.sessions.get({ sessionID }) It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. +Embedded hosts are silent by default. Set `log` to receive structured log entries at the selected minimum level: + +```ts +const opencode = + yield * + OpenCode.create({ + log: { + level: "warn", + emit: (entry) => console.error(entry.message, entry.attributes, entry.cause), + }, + }) +``` + `sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. The same constructor is available as a service Layer: @@ -26,4 +39,4 @@ const program = Effect.gen(function* () { yield * program.pipe(Effect.provide(OpenCode.layer)) ``` -`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation. +`OpenCode.layer` adapts the silent default `OpenCode.create()` for dependency injection; use `OpenCode.layerWith(options)` to configure the host. diff --git a/packages/sdk-next/src/logging.ts b/packages/sdk-next/src/logging.ts new file mode 100644 index 0000000000..ba4b89b9ba --- /dev/null +++ b/packages/sdk-next/src/logging.ts @@ -0,0 +1,71 @@ +import { Context, Formatter, Layer, Logger, References } from "effect" + +export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" + +export type LogEntry = { + readonly level: LogLevel + readonly message: string + readonly attributes?: Readonly> + readonly cause?: unknown +} + +export type LogWriter = (entry: LogEntry) => void + +export type LogOptions = { + readonly level?: LogLevel + readonly emit: LogWriter +} + +const levels: Record["logLevel"]> = { + trace: "Trace", + debug: "Debug", + info: "Info", + warn: "Warn", + error: "Error", + fatal: "Fatal", +} + +function normalizeLevel(level: Logger.Options["logLevel"]): LogLevel | undefined { + const output = Object.fromEntries(Object.entries(levels).map(([name, effect]) => [effect, name])) + return output[level] as LogLevel +} + +export function layer(log?: LogOptions) { + const logger = Logger.make((options) => { + if (!log) return + const level = normalizeLevel(options.logLevel) + if (!level) return + const entry = Logger.formatStructured.log(options) + const values = Array.isArray(entry.message) ? entry.message : [entry.message] + const [message, ...data] = values + const details = + data.length === 1 && !Array.isArray(data[0]) ? (data[0] as Readonly>) : undefined + const { cause: detailCause, ...detailAttributes } = details ?? {} + const attributes = { + ...entry.annotations, + ...detailAttributes, + ...(Object.keys(entry.spans).length > 0 ? { spans: entry.spans } : {}), + ...(!details && data.length > 0 ? { data: data.length === 1 ? data[0] : data } : {}), + } + try { + log.emit({ + level, + message: typeof message === "string" ? message : Formatter.format(message), + ...(Object.keys(attributes).length > 0 ? { attributes } : {}), + ...(entry.cause === undefined && detailCause === undefined ? {} : { cause: entry.cause ?? detailCause }), + }) + } catch { + // A host logger must not break OpenCode operations. + } + }) + return Layer.merge( + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, levels[log?.level ?? "info"]), + ) +} + +export function context(source: Context.Context) { + return Context.make(Logger.CurrentLoggers, Context.get(source, Logger.CurrentLoggers)).pipe( + Context.add(References.MinimumLogLevel, Context.get(source, References.MinimumLogLevel)), + ) +} diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 90495c964a..5a21e09956 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -2,18 +2,27 @@ import { OpenCode } from "@opencode-ai/client/effect" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import type { ServerOptions } from "@opencode-ai/server/options" -import { Context, Effect, Layer, ManagedRuntime } from "effect" -import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" +import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect" +import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" +import * as Logging from "./logging" -export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) { +export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging" +import type { LogOptions } from "./logging" + +export type CreateOptions = ServerOptions & { + readonly log?: LogOptions +} + +export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) { + const { log, ...server } = options const runtime = yield* Effect.acquireRelease( Effect.sync(() => ManagedRuntime.make( createEmbeddedRoutes({ - ...options, - app: { ...options.app, name: options.app?.name ?? "sdk" }, - database: { path: ":memory:", ...options.database }, - }).pipe(Layer.provide(HttpServer.layerServices)), + ...server, + app: { ...server.app, name: server.app?.name ?? "sdk" }, + database: { path: ":memory:", ...server.database }, + }).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))), ), ), (runtime) => runtime.disposeEffect, @@ -21,7 +30,9 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt const context = yield* runtime.contextEffect const plugins = Context.get(context, SdkPlugins.Service) const router = Context.get(context, HttpRouter.HttpRouter) - const handler = HttpEffect.toWebHandler(router.asHttpEffect()) + const handler = HttpEffect.toWebHandlerWith( + Logging.context(context), + )(router.asHttpEffect()) const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), { preconnect: () => undefined, }) satisfies typeof globalThis.fetch