export * as InstructionDiscovery from "./instruction-discovery.js" import { Context, Effect, Layer, Schema, Types } from "effect" import { makeLocationNode } from "@opencode/util/effect/app-node" import { createPatch } from "diff" import { Bus } from "./bus.js" import { Instructions } from "./instructions/index.js" import { State } from "./state.js" export class File extends Schema.Class("InstructionDiscovery.File")({ // An absolute file path, or the URL of a configured remote instruction. path: Schema.String, content: Schema.String, }) {} const Files = Schema.Array(File) const key = Instructions.Key.make("core/instructions") export const Event = { Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }), } export type Data = { files: Map> available: boolean } export type Editor = { list: () => readonly Types.DeepMutable[] // Map insertion order is render order: config adds global then nearest-to-farthest project files; // sibling contributors interleave by transform registration order. add: (file: File) => void update: (path: string, update: (file: Types.DeepMutable) => void) => void remove: (path: string) => void unavailable: () => void } export interface Interface extends State.Transformable { // Discovery policy lives here because internal plugins have no per-composition options channel. // Move it into plugin config once plugins can consume their own options. readonly project: boolean readonly global: boolean readonly list: () => Effect.Effect readonly load: () => Effect.Effect } export const Options = Schema.Struct({ project: Schema.optional(Schema.Boolean), global: Schema.optional(Schema.Boolean), }) export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/InstructionDiscovery") {} export const layer = (options?: Options) => Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const state = State.create({ name: "instruction-discovery", initial: () => ({ files: new Map(), available: true }), editor: (editor) => ({ list: () => Array.from(editor.files.values()), add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable), update: (path, update) => { const current = editor.files.get(path) if (!current) return update(current) current.path = path }, remove: (path) => editor.files.delete(path), unavailable: () => { editor.available = false }, }), notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid), }) const source = (value: ReadonlyArray | Instructions.Unavailable | Instructions.Removed) => Instructions.make>({ key, codec: Schema.toCodecJson(Files), read: Effect.succeed(value), render: { initial: render, changed: renderUpdate, removed: () => "Previously loaded instructions no longer apply.", }, }) const list = Effect.fn("InstructionDiscovery.list")(function* () { const current = state.get() if (!current.available) return Instructions.unavailable return Array.from(current.files.values()) }) return Service.of({ project: options?.project !== false, global: options?.global !== false, transform: state.transform, reload: state.reload, list, load: Effect.fn("InstructionDiscovery.load")(function* () { const files = yield* list() if (!Array.isArray(files)) return source(files) return source(files.length === 0 ? Instructions.removed : files) }), }) }), ) export function configured(options?: Options) { return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node], }) } export const node = configured() function render(files: ReadonlyArray) { return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") } function renderUpdate(previous: ReadonlyArray, current: ReadonlyArray) { const changes = Instructions.diffByKey( previous, current, (file) => file.path, (before, after) => before.content !== after.content, ) const parts = [ ...changes.removed.map((file) => `The instructions from ${file.path} no longer apply.`), ...changes.added.map((file) => `New instructions apply from:\n${render([file])}`), ...changes.changed.map(({ previous: before, current: after }) => { const patch = createPatch(after.path, before.content, after.content, "", "", { context: 3 }) const diff = [ `The instructions from ${after.path} changed. Here's the diff:`, "```diff", patch.slice(patch.indexOf("@@")).trimEnd(), "```", ].join("\n") const replacement = `The instructions changed:\n${render([after])}` return diff.length < replacement.length ? diff : replacement }), ] return parts.length === 0 ? undefined : parts.join("\n\n") }