From 2d5f6f3478eb76cf3c7b68548f2973af46e03330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 13 May 2026 00:23:04 -0300 Subject: [PATCH] refactor: rebase from main --- packages/opencode/src/config/agent.ts | 1 + packages/opencode/src/config/markdown.ts | 166 +++---- packages/opencode/src/session/instruction.ts | 407 +++++++++--------- .../session/instruction-substitution.test.ts | 83 ++-- 4 files changed, 337 insertions(+), 320 deletions(-) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index f13ec67173d..184690b7b28 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -1,5 +1,6 @@ export * as ConfigAgent from "./agent" +import path from "path" import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" import { zod } from "@/util/effect-zod" diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 6790e084d4e..ba4aca9af3e 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -4,100 +4,108 @@ import { z } from "zod" import { Filesystem } from "@/util/filesystem" import { KilocodeMarkdown } from "../kilocode/config/markdown" // kilocode_change -export namespace ConfigMarkdown { - export const FILE_REGEX = /(?" || value === "|" || value.startsWith('"') || value.startsWith("'")) { - result.push(line) - continue - } - - // if value contains a colon, convert to block scalar - if (value.includes(":")) { - result.push(`${key}: |-`) - result.push(` ${value}`) - continue - } + const frontmatter = match[1] + const lines = frontmatter.split(/\r?\n/) + const result: string[] = [] + for (const line of lines) { + // skip comments and empty lines + if (line.trim().startsWith("#") || line.trim() === "") { result.push(line) + continue } - const processed = result.join("\n") - return content.replace(frontmatter, () => processed) + // skip lines that are continuations (indented) + if (line.match(/^\s+/)) { + result.push(line) + continue + } + + // match key: value pattern + const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/) + if (!kvMatch) { + result.push(line) + continue + } + + const key = kvMatch[1] + const value = kvMatch[2].trim() + + // skip if value is empty, already quoted, or uses block scalar + if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) { + result.push(line) + continue + } + + // if value contains a colon, convert to block scalar + if (value.includes(":")) { + result.push(`${key}: |`) + result.push(` ${value}`) + continue + } + + result.push(line) } - export async function parse(filePath: string) { - const template = await Filesystem.readText(filePath) + const processed = result.join("\n") + return content.replace(frontmatter, () => processed) +} +export async function parse(filePath: string) { + const template = await Filesystem.readText(filePath) + + try { + const md = matter(template) + md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change + return md + } catch { try { - const md = matter(template) + const md = matter(fallbackSanitization(template)) md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change return md - } catch { - try { - const md = matter(fallbackSanitization(template)) - md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change - return md - } catch (err) { - throw new FrontmatterError( - { - path: filePath, - message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, - }, - { cause: err }, - ) - } + } catch (err) { + throw new FrontmatterError( + { + path: filePath, + message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, + }, + { cause: err }, + ) } } +} - export const FrontmatterError = NamedError.create( - "ConfigFrontmatterError", - z.object({ - path: z.string(), - message: z.string(), - }), - ) +export const FrontmatterError = NamedError.create( + "ConfigFrontmatterError", + z.object({ + path: z.string(), + message: z.string(), + }), +) + +export const ConfigMarkdown = { + FILE_REGEX, + SHELL_REGEX, + files, + shell, + fallbackSanitization, + parse, + FrontmatterError, } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index ab09ddffafc..7d3ef29a83a 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,40 +1,22 @@ -import os from "os" import path from "path" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" -import { Flag } from "@/flag/flag" -import { AppFileSystem } from "@/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" +import { Global } from "@opencode-ai/core/global" import { KilocodeInstruction } from "@/kilocode/session/instruction" // kilocode_change -import { Global } from "../global" -import { Instance } from "../project/instance" -import { Log } from "../util/log" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" -const log = Log.create({ service: "instruction" }) - const FILES = [ "AGENTS.md", ...(Flag.KILO_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]), "CONTEXT.md", // deprecated ] -function globalFiles() { - const files = [] - if (Flag.KILO_CONFIG_DIR) { - files.push(path.join(Flag.KILO_CONFIG_DIR, "AGENTS.md")) - } - files.push(path.join(Global.Path.config, "AGENTS.md")) - if (!Flag.KILO_DISABLE_CLAUDE_CODE_PROMPT) { - files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) - } - return files -} - function extract(messages: MessageV2.WithParts[]) { const paths = new Set() for (const msg of messages) { @@ -52,209 +34,204 @@ function extract(messages: MessageV2.WithParts[]) { return paths } -export namespace Instruction { - export interface Interface { - readonly clear: (messageID: MessageID) => Effect.Effect - readonly systemPaths: () => Effect.Effect, AppFileSystem.Error> - readonly system: () => Effect.Effect - readonly find: (dir: string) => Effect.Effect - readonly resolve: ( +export interface Interface { + readonly clear: (messageID: MessageID) => Effect.Effect + readonly systemPaths: () => Effect.Effect, AppFileSystem.Error> + readonly system: () => Effect.Effect + readonly find: (dir: string) => Effect.Effect + readonly resolve: ( + messages: MessageV2.WithParts[], + filepath: string, + messageID: MessageID, + ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error> +} + +export class Service extends Context.Service()("@opencode/Instruction") {} + +export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient +> = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const fs = yield* AppFileSystem.Service + const global = yield* Global.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + const globalFiles = [ + // kilocode_change start - prefer KILO_CONFIG_DIR profile when set + ...(Flag.KILO_CONFIG_DIR ? [path.join(Flag.KILO_CONFIG_DIR, "AGENTS.md")] : []), + // kilocode_change end + path.join(global.config, "AGENTS.md"), + ...(!Flag.KILO_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), + ] + + const state = yield* InstanceState.make( + Effect.fn("Instruction.state")(() => + Effect.succeed({ + // Track which instruction files have already been attached for a given assistant message. + claims: new Map>(), + }), + ), + ) + + const relative = Effect.fnUntraced(function* (instruction: string) { + const ctx = yield* InstanceState.context + if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { + return yield* fs + .globUp(instruction, ctx.directory, ctx.worktree) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + } + // kilocode_change - prefer KILO_CONFIG_DIR profile when set, else fall back to global.config + const root = Flag.KILO_CONFIG_DIR ?? global.config + return yield* fs.globUp(instruction, root, root).pipe(Effect.catch(() => Effect.succeed([] as string[]))) // kilocode_change + }) + + const read = Effect.fnUntraced(function* (filepath: string) { + const content = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) + return yield* Effect.promise(() => KilocodeInstruction.content(content, filepath)) // kilocode_change + }) + + const fetch = Effect.fnUntraced(function* (url: string) { + const res = yield* http.execute(HttpClientRequest.get(url)).pipe( + Effect.timeout(5000), + Effect.catch(() => Effect.succeed(null)), + ) + if (!res) return "" + const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) + return new TextDecoder().decode(body) + }) + + const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { + const s = yield* InstanceState.get(state) + s.claims.delete(messageID) + }) + + const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { + const config = yield* cfg.get() + const ctx = yield* InstanceState.context + const paths = new Set() + + for (const file of globalFiles) { + if (yield* fs.existsSafe(file)) { + paths.add(path.resolve(file)) + break + } + } + + // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. + if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { + for (const file of FILES) { + const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) + if (matches.length > 0) { + matches.forEach((item) => paths.add(path.resolve(item))) + break + } + } + } + + if (config.instructions) { + for (const raw of config.instructions) { + if (raw.startsWith("https://") || raw.startsWith("http://")) continue + const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw + const matches = yield* ( + path.isAbsolute(instruction) + ? fs.glob(path.basename(instruction), { + cwd: path.dirname(instruction), + absolute: true, + include: "file", + }) + : relative(instruction) + ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + matches.forEach((item) => paths.add(path.resolve(item))) + } + } + + return paths + }) + + const system = Effect.fn("Instruction.system")(function* () { + const config = yield* cfg.get() + const paths = yield* systemPaths() + const urls = (config.instructions ?? []).filter( + (item) => item.startsWith("https://") || item.startsWith("http://"), + ) + + const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) + const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) + + return [ + ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), + ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), + ] + }) + + const find = Effect.fn("Instruction.find")(function* (dir: string) { + for (const file of FILES) { + const filepath = path.resolve(path.join(dir, file)) + if (yield* fs.existsSafe(filepath)) return filepath + } + return undefined + }) + + const resolve = Effect.fn("Instruction.resolve")(function* ( messages: MessageV2.WithParts[], filepath: string, messageID: MessageID, - ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error> - } + ) { + const sys = yield* systemPaths() + const already = extract(messages) + const results: { filepath: string; content: string }[] = [] + const s = yield* InstanceState.get(state) + const root = path.resolve(yield* InstanceState.directory) - export class Service extends ServiceMap.Service()("@opencode/Instruction") {} + const target = path.resolve(filepath) + let current = path.dirname(target) - export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const fs = yield* AppFileSystem.Service - const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + // Walk upward from the file being read and attach nearby instruction files once per message. + while (current.startsWith(root) && current !== root) { + const found = yield* find(current) + if (!found || found === target || sys.has(found) || already.has(found)) { + current = path.dirname(current) + continue + } - const state = yield* InstanceState.make( - Effect.fn("Instruction.state")(() => - Effect.succeed({ - // Track which instruction files have already been attached for a given assistant message. - claims: new Map>(), - }), - ), - ) + let set = s.claims.get(messageID) + if (!set) { + set = new Set() + s.claims.set(messageID, set) + } + if (set.has(found)) { + current = path.dirname(current) + continue + } - const relative = Effect.fnUntraced(function* (instruction: string) { - if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { - return yield* fs - .globUp(instruction, Instance.directory, Instance.worktree) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - } - if (!Flag.KILO_CONFIG_DIR) { - log.warn( - `Skipping relative instruction "${instruction}" - no KILO_CONFIG_DIR set while project config is disabled`, - ) - return [] - } - return yield* fs - .globUp(instruction, Flag.KILO_CONFIG_DIR, Flag.KILO_CONFIG_DIR) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - }) + set.add(found) + const content = yield* read(found) + if (content) { + results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) + } - const read = Effect.fnUntraced(function* (filepath: string) { - const content = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) - return yield* Effect.promise(() => KilocodeInstruction.content(content, filepath)) // kilocode_change - }) + current = path.dirname(current) + } - const fetch = Effect.fnUntraced(function* (url: string) { - const res = yield* http.execute(HttpClientRequest.get(url)).pipe( - Effect.timeout(5000), - Effect.catch(() => Effect.succeed(null)), - ) - if (!res) return "" - const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) - return new TextDecoder().decode(body) - }) + return results + }) - const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { - const s = yield* InstanceState.get(state) - s.claims.delete(messageID) - }) + return Service.of({ clear, systemPaths, system, find, resolve }) + }), +) - const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { - const config = yield* cfg.get() - const paths = new Set() +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Global.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(FetchHttpClient.layer), +) - // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. - if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { - for (const file of FILES) { - const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree) - if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) - break - } - } - } - - for (const file of globalFiles()) { - if (yield* fs.existsSafe(file)) { - paths.add(path.resolve(file)) - break - } - } - - if (config.instructions) { - for (const raw of config.instructions) { - if (raw.startsWith("https://") || raw.startsWith("http://")) continue - const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw - const matches = yield* ( - path.isAbsolute(instruction) - ? fs.glob(path.basename(instruction), { - cwd: path.dirname(instruction), - absolute: true, - include: "file", - }) - : relative(instruction) - ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) - matches.forEach((item) => paths.add(path.resolve(item))) - } - } - - return paths - }) - - const system = Effect.fn("Instruction.system")(function* () { - const config = yield* cfg.get() - const paths = yield* systemPaths() - const urls = (config.instructions ?? []).filter( - (item) => item.startsWith("https://") || item.startsWith("http://"), - ) - - const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) - const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) - - return [ - ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), - ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), - ] - }) - - const find = Effect.fn("Instruction.find")(function* (dir: string) { - for (const file of FILES) { - const filepath = path.resolve(path.join(dir, file)) - if (yield* fs.existsSafe(filepath)) return filepath - } - }) - - const resolve = Effect.fn("Instruction.resolve")(function* ( - messages: MessageV2.WithParts[], - filepath: string, - messageID: MessageID, - ) { - const sys = yield* systemPaths() - const already = extract(messages) - const results: { filepath: string; content: string }[] = [] - const s = yield* InstanceState.get(state) - - const target = path.resolve(filepath) - const root = path.resolve(Instance.directory) - let current = path.dirname(target) - - // Walk upward from the file being read and attach nearby instruction files once per message. - while (current.startsWith(root) && current !== root) { - const found = yield* find(current) - if (!found || found === target || sys.has(found) || already.has(found)) { - current = path.dirname(current) - continue - } - - let set = s.claims.get(messageID) - if (!set) { - set = new Set() - s.claims.set(messageID, set) - } - if (set.has(found)) { - current = path.dirname(current) - continue - } - - set.add(found) - const content = yield* read(found) - if (content) { - results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) - } - - current = path.dirname(current) - } - - return results - }) - - return Service.of({ clear, systemPaths, system, find, resolve }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(FetchHttpClient.layer), - ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function clear(messageID: MessageID) { - return runPromise((svc) => svc.clear(messageID)) - } - - export async function systemPaths() { - return runPromise((svc) => svc.systemPaths()) - } - - export function loaded(messages: MessageV2.WithParts[]) { - return extract(messages) - } - - export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) { - return runPromise((svc) => svc.resolve(messages, filepath, messageID)) - } +export function loaded(messages: MessageV2.WithParts[]) { + return extract(messages) } + +export * as Instruction from "./instruction" diff --git a/packages/opencode/test/kilocode/session/instruction-substitution.test.ts b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts index 9215ed1f00a..abe9e777343 100644 --- a/packages/opencode/test/kilocode/session/instruction-substitution.test.ts +++ b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts @@ -1,37 +1,67 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import path from "node:path" -import { tmpdir } from "../../fixture/fixture" +import { Effect, FileSystem, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { NodeFileSystem } from "@effect/platform-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Config } from "../../../src/config/config" +import { emptyConsoleState } from "../../../src/config/console-state" import { Instruction } from "../../../src/session/instruction" -import { Instance } from "../../../src/project/instance" import { MessageID } from "../../../src/session/schema" -import { Filesystem } from "../../../src/util/filesystem" +import { Global } from "@opencode-ai/core/global" +import { provideTmpdirInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" -afterEach(async () => { - delete process.env.KILO_INSTRUCTION_TEST - await Instance.disposeAll() -}) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) + +const configLayer = Layer.succeed( + Config.Service, + Config.Service.of({ + get: () => Effect.succeed({}), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed(emptyConsoleState), + update: () => Effect.void, + updateGlobal: (config) => Effect.succeed(config), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + warnings: () => Effect.succeed([]), + }), +) + +const layer = (dir: string) => + Instruction.layer.pipe( + Layer.provide(configLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Global.layerWith({ home: dir, config: dir })), + ) + +const write = (filepath: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.dirname(filepath), { recursive: true }) + yield* fs.writeFileString(filepath, content) + }) describe("instruction markdown substitutions", () => { - test("applies file and env substitutions to nearby AGENTS.md", async () => { - process.env.KILO_INSTRUCTION_TEST = "env content" - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write(path.join(dir, "subdir", "guide.md"), "file content") - await Filesystem.write( + it.live("applies file and env substitutions to nearby AGENTS.md", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + process.env.KILO_INSTRUCTION_TEST = "env content" + yield* write(path.join(dir, "subdir", "guide.md"), "file content") + yield* write( path.join(dir, "subdir", "AGENTS.md"), ["# Instructions", "", "{file:guide.md}", "{env:KILO_INSTRUCTION_TEST}"].join("\n"), ) - await Filesystem.write(path.join(dir, "subdir", "nested", "file.ts"), "const value = 1") - }, - }) + yield* write(path.join(dir, "subdir", "nested", "file.ts"), "const value = 1") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const results = await Instruction.resolve( + const svc = yield* Instruction.Service + const results = yield* svc.resolve( [], - path.join(tmp.path, "subdir", "nested", "file.ts"), - MessageID.make("message-instruction-substitution"), + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.ascending(), ) expect(results).toHaveLength(1) @@ -39,7 +69,8 @@ describe("instruction markdown substitutions", () => { expect(results[0].content).toContain("env content") expect(results[0].content).not.toContain("{file:") expect(results[0].content).not.toContain("{env:") - }, - }) - }) + delete process.env.KILO_INSTRUCTION_TEST + }).pipe(Effect.provide(layer(dir))), + ), + ) })