From 80edeee58f89d1be1537679182b927f62fd73e6f Mon Sep 17 00:00:00 2001 From: Maksym Mospanenko Date: Sat, 4 Apr 2026 22:18:47 +0300 Subject: [PATCH 1/8] feat(config): add {file:...} syntax support for agent markdown prompts Enable file content substitution in agent configuration prompts using the existing `ConfigPaths.substitute` utility. This allows agent markdown files to reference external file contents via `{file:path}` syntax, with missing files resolving to empty strings. Export `substitute` function from ConfigPaths to support agent config processing. --- packages/opencode/src/config/agent.ts | 11 ++- .../test/config/agent-file-syntax.test.ts | 88 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/config/agent-file-syntax.test.ts diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index cea0148bb95..edb3d5be983 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -13,6 +13,7 @@ import * as ConfigMarkdown from "./markdown" import { ConfigModelID } from "./model-id" import { ConfigParse } from "./parse" import { ConfigPermission } from "./permission" +import { ConfigVariable } from "./variable" // kilocode_change start import { KilocodeConfig } from "@/kilocode/config/config" import type { Warning } from "./config" @@ -162,10 +163,18 @@ export async function load(dir: string, warnings?: Warning[]) { // kilocode_change end const name = configEntryNameFromPath(item, patterns) + const prompt = await ConfigVariable.substitute({ + text: md.content.trim(), + type: "virtual", + dir: path.dirname(item), + source: item, + missing: "empty", + }) + const config = { name, ...md.data, - prompt: md.content.trim(), + prompt, } // kilocode_change start - use Effect schema (propertyOrder: original) + non-fatal handleInvalid try { diff --git a/packages/opencode/test/config/agent-file-syntax.test.ts b/packages/opencode/test/config/agent-file-syntax.test.ts new file mode 100644 index 00000000000..cf0420f4ffc --- /dev/null +++ b/packages/opencode/test/config/agent-file-syntax.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import fs from "node:fs/promises" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Agent } from "../../src/agent/agent" + +describe("agent {file:...} syntax in markdown body", () => { + test("loads file content when {file:...} is used in agent body", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "prompts", "guide.md"), "Guidelines content") + + const agentsDir = path.join(dir, ".kilo", "agents") + await fs.mkdir(agentsDir, { recursive: true }) + await Bun.write( + path.join(agentsDir, "test.md"), + ["---", "description: Test Agent", "---", "Base prompt", "", "{file:../../prompts/guide.md}", ""].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("test") + + expect(agent).toBeDefined() + expect(agent?.prompt).toContain("Base prompt") + expect(agent?.prompt).toContain("Guidelines content") + }, + }) + }) + + test("replaces missing file reference with empty string", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const agentsDir = path.join(dir, ".kilo", "agents") + await fs.mkdir(agentsDir, { recursive: true }) + await Bun.write( + path.join(agentsDir, "test.md"), + ["---", "description: Test Agent", "---", "Base prompt", "", "{file:missing.txt}", ""].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("test") + + expect(agent).toBeDefined() + expect(agent?.prompt).toContain("Base prompt") + expect(agent?.prompt).not.toContain("{file:") + }, + }) + }) + + test("resolves multiple file references in same agent", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "a.md"), "Content A") + await Bun.write(path.join(dir, "b.md"), "Content B") + + const agentsDir = path.join(dir, ".kilo", "agents") + await fs.mkdir(agentsDir, { recursive: true }) + await Bun.write( + path.join(agentsDir, "test.md"), + ["---", "description: Test", "---", "Base", "", "{file:../../a.md}", "{file:../../b.md}", ""].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("test") + + expect(agent).toBeDefined() + expect(agent?.prompt).toContain("Content A") + expect(agent?.prompt).toContain("Content B") + }, + }) + }) +}) From 3ca9941bbf1a3144768c422c0607e1529bdabbf6 Mon Sep 17 00:00:00 2001 From: Maksym Mospanenko Date: Sat, 4 Apr 2026 22:37:59 +0300 Subject: [PATCH 2/8] feat(config): add `escapeJson` parameter to `substitute` for raw content handling Add optional `escapeJson` parameter to `ConfigPaths.substitute` to control whether file content should be JSON-escaped. When `false`, multiline content and special characters are preserved as-is, which is required for agent markdown prompts that need raw file content rather than escaped strings. Update agent config processing to pass `escapeJson: false` when substituting file content into markdown prompts, ensuring proper handling of multiline files without unwanted escape sequences. Add comprehensive test coverage for the new parameter and existing substitution functionality. --- packages/opencode/src/config/agent.ts | 1 + packages/opencode/src/config/variable.ts | 4 +- .../test/config/agent-file-syntax.test.ts | 28 ++++++ packages/opencode/test/config/paths.test.ts | 99 +++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/config/paths.test.ts diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index edb3d5be983..f13ec67173d 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -169,6 +169,7 @@ export async function load(dir: string, warnings?: Warning[]) { dir: path.dirname(item), source: item, missing: "empty", + escapeJson: false, }) const config = { diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index e61e06d41bb..77ed281de9f 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -19,6 +19,7 @@ type ParseSource = type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" + escapeJson?: boolean } function source(input: ParseSource) { @@ -32,6 +33,7 @@ function dir(input: ParseSource) { /** Apply {env:VAR} and {file:path} substitutions to config text. */ export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" + const escape = input.escapeJson ?? true let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) @@ -81,7 +83,7 @@ export async function substitute(input: SubstituteInput) { }) ).trim() - out += JSON.stringify(fileContent).slice(1, -1) + out += escape ? JSON.stringify(fileContent).slice(1, -1) : fileContent cursor = index + token.length } diff --git a/packages/opencode/test/config/agent-file-syntax.test.ts b/packages/opencode/test/config/agent-file-syntax.test.ts index cf0420f4ffc..d6786d324da 100644 --- a/packages/opencode/test/config/agent-file-syntax.test.ts +++ b/packages/opencode/test/config/agent-file-syntax.test.ts @@ -85,4 +85,32 @@ describe("agent {file:...} syntax in markdown body", () => { }, }) }) + + test("preserves multiline content without escaping", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const multilineContent = ["Line 1", "Line 2", "Line 3"].join("\n") + await Bun.write(path.join(dir, "multiline.md"), multilineContent) + + const agentsDir = path.join(dir, ".kilo", "agents") + await fs.mkdir(agentsDir, { recursive: true }) + await Bun.write( + path.join(agentsDir, "test.md"), + ["---", "description: Test", "---", "Base", "", "{file:../../multiline.md}", ""].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("test") + + expect(agent).toBeDefined() + expect(agent?.prompt).toContain("Line 1\nLine 2\nLine 3") + expect(agent?.prompt).not.toContain("Line 1\\nLine 2\\nLine 3") + }, + }) + }) }) diff --git a/packages/opencode/test/config/paths.test.ts b/packages/opencode/test/config/paths.test.ts new file mode 100644 index 00000000000..101623d4753 --- /dev/null +++ b/packages/opencode/test/config/paths.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import fs from "node:fs/promises" +import { tmpdir } from "../fixture/fixture" +import { ConfigPaths } from "../../src/config/paths" + +describe("ConfigPaths.substitute", () => { + describe("escapeJson parameter", () => { + test("escapes newlines when escapeJson is true (default)", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "Line 1\nLine 2\nLine 3") + }, + }) + + const result = await ConfigPaths.substitute( + "{file:./test.txt}", + { dir: tmp.path, source: "config.json" }, + "error", + true, + ) + + expect(result).toBe("Line 1\\nLine 2\\nLine 3") + }) + + test("preserves newlines when escapeJson is false", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "Line 1\nLine 2\nLine 3") + }, + }) + + const result = await ConfigPaths.substitute( + "{file:./test.txt}", + { dir: tmp.path, source: "agent.md" }, + "empty", + false, + ) + + expect(result).toBe("Line 1\nLine 2\nLine 3") + }) + + test("escapes quotes when escapeJson is true", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), 'Text with "quotes"') + }, + }) + + const result = await ConfigPaths.substitute( + "{file:./test.txt}", + { dir: tmp.path, source: "config.json" }, + "error", + true, + ) + + expect(result).toBe('Text with \\"quotes\\"') + }) + + test("preserves quotes when escapeJson is false", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), 'Text with "quotes"') + }, + }) + + const result = await ConfigPaths.substitute( + "{file:./test.txt}", + { dir: tmp.path, source: "agent.md" }, + "empty", + false, + ) + + expect(result).toBe('Text with "quotes"') + }) + }) + + describe("existing functionality", () => { + test("substitutes {env:VAR} with environment variable", async () => { + process.env.TEST_VAR = "test_value" + + const result = await ConfigPaths.substitute("Value: {env:TEST_VAR}", { dir: "/tmp", source: "test.json" }) + + expect(result).toBe("Value: test_value") + + delete process.env.TEST_VAR + }) + + test("replaces missing {env:VAR} with empty string", async () => { + const result = await ConfigPaths.substitute("Value: {env:NONEXISTENT_VAR}", { dir: "/tmp", source: "test.json" }) + + expect(result).toBe("Value: ") + }) + }) +}) From 8d23e17141e85d6042bf880d4114fe008852f123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Tue, 12 May 2026 21:06:55 -0300 Subject: [PATCH 3/8] refactor: isolate kilochange --- .../test/config/agent-file-syntax.test.ts | 116 ------------------ packages/opencode/test/config/paths.test.ts | 99 --------------- 2 files changed, 215 deletions(-) delete mode 100644 packages/opencode/test/config/agent-file-syntax.test.ts delete mode 100644 packages/opencode/test/config/paths.test.ts diff --git a/packages/opencode/test/config/agent-file-syntax.test.ts b/packages/opencode/test/config/agent-file-syntax.test.ts deleted file mode 100644 index d6786d324da..00000000000 --- a/packages/opencode/test/config/agent-file-syntax.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "node:path" -import fs from "node:fs/promises" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" -import { Agent } from "../../src/agent/agent" - -describe("agent {file:...} syntax in markdown body", () => { - test("loads file content when {file:...} is used in agent body", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "prompts", "guide.md"), "Guidelines content") - - const agentsDir = path.join(dir, ".kilo", "agents") - await fs.mkdir(agentsDir, { recursive: true }) - await Bun.write( - path.join(agentsDir, "test.md"), - ["---", "description: Test Agent", "---", "Base prompt", "", "{file:../../prompts/guide.md}", ""].join("\n"), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await Agent.get("test") - - expect(agent).toBeDefined() - expect(agent?.prompt).toContain("Base prompt") - expect(agent?.prompt).toContain("Guidelines content") - }, - }) - }) - - test("replaces missing file reference with empty string", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const agentsDir = path.join(dir, ".kilo", "agents") - await fs.mkdir(agentsDir, { recursive: true }) - await Bun.write( - path.join(agentsDir, "test.md"), - ["---", "description: Test Agent", "---", "Base prompt", "", "{file:missing.txt}", ""].join("\n"), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await Agent.get("test") - - expect(agent).toBeDefined() - expect(agent?.prompt).toContain("Base prompt") - expect(agent?.prompt).not.toContain("{file:") - }, - }) - }) - - test("resolves multiple file references in same agent", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "a.md"), "Content A") - await Bun.write(path.join(dir, "b.md"), "Content B") - - const agentsDir = path.join(dir, ".kilo", "agents") - await fs.mkdir(agentsDir, { recursive: true }) - await Bun.write( - path.join(agentsDir, "test.md"), - ["---", "description: Test", "---", "Base", "", "{file:../../a.md}", "{file:../../b.md}", ""].join("\n"), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await Agent.get("test") - - expect(agent).toBeDefined() - expect(agent?.prompt).toContain("Content A") - expect(agent?.prompt).toContain("Content B") - }, - }) - }) - - test("preserves multiline content without escaping", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const multilineContent = ["Line 1", "Line 2", "Line 3"].join("\n") - await Bun.write(path.join(dir, "multiline.md"), multilineContent) - - const agentsDir = path.join(dir, ".kilo", "agents") - await fs.mkdir(agentsDir, { recursive: true }) - await Bun.write( - path.join(agentsDir, "test.md"), - ["---", "description: Test", "---", "Base", "", "{file:../../multiline.md}", ""].join("\n"), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await Agent.get("test") - - expect(agent).toBeDefined() - expect(agent?.prompt).toContain("Line 1\nLine 2\nLine 3") - expect(agent?.prompt).not.toContain("Line 1\\nLine 2\\nLine 3") - }, - }) - }) -}) diff --git a/packages/opencode/test/config/paths.test.ts b/packages/opencode/test/config/paths.test.ts deleted file mode 100644 index 101623d4753..00000000000 --- a/packages/opencode/test/config/paths.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "node:path" -import fs from "node:fs/promises" -import { tmpdir } from "../fixture/fixture" -import { ConfigPaths } from "../../src/config/paths" - -describe("ConfigPaths.substitute", () => { - describe("escapeJson parameter", () => { - test("escapes newlines when escapeJson is true (default)", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "test.txt"), "Line 1\nLine 2\nLine 3") - }, - }) - - const result = await ConfigPaths.substitute( - "{file:./test.txt}", - { dir: tmp.path, source: "config.json" }, - "error", - true, - ) - - expect(result).toBe("Line 1\\nLine 2\\nLine 3") - }) - - test("preserves newlines when escapeJson is false", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "test.txt"), "Line 1\nLine 2\nLine 3") - }, - }) - - const result = await ConfigPaths.substitute( - "{file:./test.txt}", - { dir: tmp.path, source: "agent.md" }, - "empty", - false, - ) - - expect(result).toBe("Line 1\nLine 2\nLine 3") - }) - - test("escapes quotes when escapeJson is true", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "test.txt"), 'Text with "quotes"') - }, - }) - - const result = await ConfigPaths.substitute( - "{file:./test.txt}", - { dir: tmp.path, source: "config.json" }, - "error", - true, - ) - - expect(result).toBe('Text with \\"quotes\\"') - }) - - test("preserves quotes when escapeJson is false", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "test.txt"), 'Text with "quotes"') - }, - }) - - const result = await ConfigPaths.substitute( - "{file:./test.txt}", - { dir: tmp.path, source: "agent.md" }, - "empty", - false, - ) - - expect(result).toBe('Text with "quotes"') - }) - }) - - describe("existing functionality", () => { - test("substitutes {env:VAR} with environment variable", async () => { - process.env.TEST_VAR = "test_value" - - const result = await ConfigPaths.substitute("Value: {env:TEST_VAR}", { dir: "/tmp", source: "test.json" }) - - expect(result).toBe("Value: test_value") - - delete process.env.TEST_VAR - }) - - test("replaces missing {env:VAR} with empty string", async () => { - const result = await ConfigPaths.substitute("Value: {env:NONEXISTENT_VAR}", { dir: "/tmp", source: "test.json" }) - - expect(result).toBe("Value: ") - }) - }) -}) From 109d59a3120b24766b13d437d3cf0dcb1da7d6fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Tue, 12 May 2026 21:32:11 -0300 Subject: [PATCH 4/8] refactor: add support for all markdown files --- packages/opencode/src/config/markdown.ts | 174 ++++---- .../opencode/src/kilocode/config/markdown.ts | 38 ++ .../src/kilocode/session/instruction.ts | 7 + .../src/kilocode/workflows-migrator.ts | 3 +- packages/opencode/src/session/instruction.ts | 407 ++++++++++-------- .../test/kilocode/config/config.test.ts | 32 +- .../session/instruction-substitution.test.ts | 45 ++ .../test/kilocode/workflows-migrator.test.ts | 23 + 8 files changed, 447 insertions(+), 282 deletions(-) create mode 100644 packages/opencode/src/kilocode/config/markdown.ts create mode 100644 packages/opencode/src/kilocode/session/instruction.ts create mode 100644 packages/opencode/test/kilocode/session/instruction-substitution.test.ts diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 390f7f8b06a..6790e084d4e 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -2,98 +2,102 @@ import { NamedError } from "@opencode-ai/core/util/error" import matter from "gray-matter" import { z } from "zod" import { Filesystem } from "@/util/filesystem" +import { KilocodeMarkdown } from "../kilocode/config/markdown" // kilocode_change -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 - } - - result.push(line) + export function files(template: string) { + return Array.from(template.matchAll(FILE_REGEX)) } - const processed = result.join("\n") - return content.replace(frontmatter, () => processed) -} + export function shell(template: string) { + return Array.from(template.matchAll(SHELL_REGEX)) + } -export async function parse(filePath: string) { - const template = await Filesystem.readText(filePath) + // other coding agents like claude code allow invalid yaml in their + // frontmatter, we need to fallback to a more permissive parser for those cases + export function fallbackSanitization(content: string): string { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return content + + 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 + } + + // 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) + } + + 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) - return md - } catch { try { - return matter(fallbackSanitization(template)) - } catch (err) { - throw new FrontmatterError( - { - path: filePath, - message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, - }, - { cause: err }, - ) + const md = matter(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 }, + ) + } } } + + 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 * as ConfigMarkdown from "./markdown" diff --git a/packages/opencode/src/kilocode/config/markdown.ts b/packages/opencode/src/kilocode/config/markdown.ts new file mode 100644 index 00000000000..2b8147a7660 --- /dev/null +++ b/packages/opencode/src/kilocode/config/markdown.ts @@ -0,0 +1,38 @@ +import os from "os" +import path from "path" +import { Filesystem } from "@/util/filesystem" + +export namespace KilocodeMarkdown { + function ref(token: string) { + const file = token.replace(/^\{file:/, "").replace(/\}$/, "") + if (file.startsWith("~/")) return path.join(os.homedir(), file.slice(2)) + return file + } + + export async function substitute(text: string, item: string) { + const body = text.replace(/\{env:([^}]+)\}/g, (_, name) => process.env[name] || "") + const matches = Array.from(body.matchAll(/\{file:[^}]+\}/g)) + if (!matches.length) return body + + const dir = path.dirname(item) + const chunks = await Promise.all( + matches.map(async (match, i) => { + const token = match[0] + const index = match.index ?? 0 + const prev = matches[i - 1] + const cursor = prev ? (prev.index ?? 0) + prev[0].length : 0 + const head = body.slice(cursor, index) + const start = body.lastIndexOf("\n", index - 1) + 1 + const prefix = body.slice(start, index).trimStart() + if (prefix.startsWith("//")) return head + token + + const file = ref(token) + const target = path.isAbsolute(file) ? file : path.resolve(dir, file) + const content = await Filesystem.readText(target).catch(() => "") + return head + content.trim() + }), + ) + const last = matches.at(-1) + return chunks.join("") + (last ? body.slice((last.index ?? 0) + last[0].length) : "") + } +} diff --git a/packages/opencode/src/kilocode/session/instruction.ts b/packages/opencode/src/kilocode/session/instruction.ts new file mode 100644 index 00000000000..cd3ec481a1c --- /dev/null +++ b/packages/opencode/src/kilocode/session/instruction.ts @@ -0,0 +1,7 @@ +import { KilocodeMarkdown } from "../config/markdown" + +export namespace KilocodeInstruction { + export function content(text: string, item: string) { + return KilocodeMarkdown.substitute(text, item) + } +} diff --git a/packages/opencode/src/kilocode/workflows-migrator.ts b/packages/opencode/src/kilocode/workflows-migrator.ts index cd915389098..c8c475c5780 100644 --- a/packages/opencode/src/kilocode/workflows-migrator.ts +++ b/packages/opencode/src/kilocode/workflows-migrator.ts @@ -4,6 +4,7 @@ import os from "os" import type { Config } from "../config/config" import type { ConfigCommand } from "../config/command" import { Filesystem } from "../util/filesystem" +import { KilocodeMarkdown } from "./config/markdown" import { KilocodePaths } from "./paths" export namespace WorkflowsMigrator { @@ -59,7 +60,7 @@ export namespace WorkflowsMigrator { const files = await findWorkflowFiles(dir) const workflows: KilocodeWorkflow[] = [] for (const file of files) { - const content = await fs.readFile(file, "utf-8") + const content = await KilocodeMarkdown.substitute(await fs.readFile(file, "utf-8"), file) workflows.push({ name: extractNameFromFilename(file), path: file, diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index d0be1dd401d..ab09ddffafc 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,21 +1,40 @@ +import os from "os" import path from "path" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" -import { Flag } from "@opencode-ai/core/flag/flag" -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { makeRuntime } from "@/effect/run-service" +import { Flag } from "@/flag/flag" +import { AppFileSystem } from "@/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) { @@ -33,203 +52,209 @@ function extract(messages: MessageV2.WithParts[]) { return paths } -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) { - return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) - }) - - 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* ( +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: ( 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 root = path.resolve(yield* InstanceState.directory) + ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error> + } - const target = path.resolve(filepath) - let current = path.dirname(target) + export class Service extends ServiceMap.Service()("@opencode/Instruction") {} - // 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 - } + 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)) - 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 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>(), + }), + ), + ) - set.add(found) - const content = yield* read(found) - if (content) { - results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) - } + 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[]))) + }) - current = path.dirname(current) - } + 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 + }) - return results - }) + 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 Service.of({ clear, systemPaths, system, find, resolve }) - }), -) + const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { + const s = yield* InstanceState.get(state) + s.claims.delete(messageID) + }) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(FetchHttpClient.layer), -) + const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { + const config = yield* cfg.get() + const paths = new Set() -export function loaded(messages: MessageV2.WithParts[]) { - return extract(messages) + // 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 * as Instruction from "./instruction" diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index cfe58435ac5..de1f9343387 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -11,6 +11,7 @@ import { Npm } from "@opencode-ai/core/npm" import { Account } from "../../../src/account/account" import { Auth } from "../../../src/auth" import { Config } from "../../../src/config/config" +import { ConfigMarkdown } from "../../../src/config/markdown" import { Env } from "../../../src/env" import { KiloIndexing } from "../../../src/kilocode/indexing" import { Instance } from "../../../src/project/instance" @@ -64,12 +65,33 @@ const cfg: Partial = { }, } -describe("kilocode indexing config", () => { - afterEach(async () => { - await disposeAllInstances() - await clear(true) - }) +afterEach(async () => { + delete process.env.KILO_MD_TEST + await disposeAllInstances() + await clear(true) +}) +describe("markdown substitutions", () => { + test("applies file and env substitutions to parsed markdown body", async () => { + process.env.KILO_MD_TEST = "env content" + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "body.md"), "file content") + await Filesystem.write( + path.join(dir, "SKILL.md"), + ["---", "name: test", "description: Test", "---", "{file:body.md}", "{env:KILO_MD_TEST}"].join("\n"), + ) + }, + }) + + const md = await ConfigMarkdown.parse(path.join(tmp.path, "SKILL.md")) + + expect(md.content).toContain("file content") + expect(md.content).toContain("env content") + }) +}) + +describe("kilocode indexing config", () => { test("keeps global indexing enabled in global config", async () => { await using globalTmp = await tmpdir() await using tmp = await tmpdir() diff --git a/packages/opencode/test/kilocode/session/instruction-substitution.test.ts b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts new file mode 100644 index 00000000000..9215ed1f00a --- /dev/null +++ b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "node:path" +import { tmpdir } from "../../fixture/fixture" +import { Instruction } from "../../../src/session/instruction" +import { Instance } from "../../../src/project/instance" +import { MessageID } from "../../../src/session/schema" +import { Filesystem } from "../../../src/util/filesystem" + +afterEach(async () => { + delete process.env.KILO_INSTRUCTION_TEST + await Instance.disposeAll() +}) + +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( + 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") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const results = await Instruction.resolve( + [], + path.join(tmp.path, "subdir", "nested", "file.ts"), + MessageID.make("message-instruction-substitution"), + ) + + expect(results).toHaveLength(1) + expect(results[0].content).toContain("file content") + expect(results[0].content).toContain("env content") + expect(results[0].content).not.toContain("{file:") + expect(results[0].content).not.toContain("{env:") + }, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/workflows-migrator.test.ts b/packages/opencode/test/kilocode/workflows-migrator.test.ts index f8f8c8ba06d..afb40c3c5e5 100644 --- a/packages/opencode/test/kilocode/workflows-migrator.test.ts +++ b/packages/opencode/test/kilocode/workflows-migrator.test.ts @@ -136,6 +136,29 @@ Actual description here.` workflows.some((w) => w.source === "global" && w.path.includes(path.join(".kilo", "workflows", "global.md"))), ).toBe(true) }) + + test("applies markdown substitutions to workflow content", async () => { + process.env.KILO_WORKFLOW_TEST = "env content" + await using tmp = await tmpdir({ + init: async (dir) => { + const workflowsDir = path.join(dir, ".kilo", "workflows") + await Bun.write(path.join(dir, "guide.md"), "file content") + await Bun.write( + path.join(workflowsDir, "workflow.md"), + ["# Workflow", "", "{file:../../guide.md}", "{env:KILO_WORKFLOW_TEST}"].join("\n"), + ) + }, + }) + + try { + const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true) + + expect(workflows[0].content).toContain("file content") + expect(workflows[0].content).toContain("env content") + } finally { + delete process.env.KILO_WORKFLOW_TEST + } + }) }) describe("convertToCommand", () => { 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 5/8] 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))), + ), + ) }) From 5b61045de61f5752975151485c12a0b6628b87fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 13 May 2026 00:29:16 -0300 Subject: [PATCH 6/8] refactor: add kilocode markers --- packages/opencode/src/config/agent.ts | 7 ++++--- packages/opencode/src/config/markdown.ts | 8 ++++++++ packages/opencode/src/config/variable.ts | 6 +++--- packages/opencode/src/session/instruction.ts | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 184690b7b28..a725ee32f32 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -1,6 +1,6 @@ export * as ConfigAgent from "./agent" -import path from "path" +import path from "path" // kilocode_change import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" import { zod } from "@/util/effect-zod" @@ -14,7 +14,7 @@ import * as ConfigMarkdown from "./markdown" import { ConfigModelID } from "./model-id" import { ConfigParse } from "./parse" import { ConfigPermission } from "./permission" -import { ConfigVariable } from "./variable" +import { ConfigVariable } from "./variable" // kilocode_change // kilocode_change start import { KilocodeConfig } from "@/kilocode/config/config" import type { Warning } from "./config" @@ -164,6 +164,7 @@ export async function load(dir: string, warnings?: Warning[]) { // kilocode_change end const name = configEntryNameFromPath(item, patterns) + // kilocode_change start - substitute agent prompt variables relative to the agent file const prompt = await ConfigVariable.substitute({ text: md.content.trim(), type: "virtual", @@ -172,12 +173,12 @@ export async function load(dir: string, warnings?: Warning[]) { missing: "empty", escapeJson: false, }) - const config = { name, ...md.data, prompt, } + // kilocode_change end // kilocode_change start - use Effect schema (propertyOrder: original) + non-fatal handleInvalid try { result[config.name] = ConfigParse.effectSchema(Info, config, item) as Info diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index ba4aca9af3e..795246cd0cf 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -56,8 +56,10 @@ export function fallbackSanitization(content: string): string { // if value contains a colon, convert to block scalar if (value.includes(":")) { + // kilocode_change start - preserve unquoted colon values as a block scalar result.push(`${key}: |`) result.push(` ${value}`) + // kilocode_change end continue } @@ -71,6 +73,7 @@ export function fallbackSanitization(content: string): string { export async function parse(filePath: string) { const template = await Filesystem.readText(filePath) + // kilocode_change start - substitute content and retry invalid frontmatter with permissive sanitization try { const md = matter(template) md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change @@ -90,8 +93,10 @@ export async function parse(filePath: string) { ) } } + // kilocode_change end } +// kilocode_change start - export structured frontmatter parse errors export const FrontmatterError = NamedError.create( "ConfigFrontmatterError", z.object({ @@ -99,7 +104,9 @@ export const FrontmatterError = NamedError.create( message: z.string(), }), ) +// kilocode_change end +// kilocode_change start - export helpers as namespace object export const ConfigMarkdown = { FILE_REGEX, SHELL_REGEX, @@ -109,3 +116,4 @@ export const ConfigMarkdown = { parse, FrontmatterError, } +// kilocode_change end diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 77ed281de9f..d36c33d890d 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -19,7 +19,7 @@ type ParseSource = type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" - escapeJson?: boolean + escapeJson?: boolean // kilocode_change } function source(input: ParseSource) { @@ -33,7 +33,7 @@ function dir(input: ParseSource) { /** Apply {env:VAR} and {file:path} substitutions to config text. */ export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" - const escape = input.escapeJson ?? true + const escape = input.escapeJson ?? true // kilocode_change let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) @@ -83,7 +83,7 @@ export async function substitute(input: SubstituteInput) { }) ).trim() - out += escape ? JSON.stringify(fileContent).slice(1, -1) : fileContent + out += escape ? JSON.stringify(fileContent).slice(1, -1) : fileContent // kilocode_change cursor = index + token.length } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 7d3ef29a83a..de3e0e08756 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -89,7 +89,7 @@ export const layer: Layer.Layer< }) const read = Effect.fnUntraced(function* (filepath: string) { - const content = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) + const content = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) // kilocode_change return yield* Effect.promise(() => KilocodeInstruction.content(content, filepath)) // kilocode_change }) From 6281664038dbed9066765102eeb964615d877e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 13 May 2026 00:33:08 -0300 Subject: [PATCH 7/8] fix: md scalar --- packages/opencode/src/config/markdown.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 795246cd0cf..cb6d0d4868a 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -54,11 +54,10 @@ export function fallbackSanitization(content: string): string { continue } - // if value contains a colon, convert to block scalar + // if value contains a colon, quote it as a scalar if (value.includes(":")) { - // kilocode_change start - preserve unquoted colon values as a block scalar - result.push(`${key}: |`) - result.push(` ${value}`) + // kilocode_change start - preserve unquoted colon values as exact strings + result.push(`${key}: "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`) // kilocode_change end continue } From b04e1a1131879f680c80cf2c0c714b10c1f55738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 13 May 2026 00:36:33 -0300 Subject: [PATCH 8/8] fix: kilocode markers --- packages/opencode/src/config/markdown.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index cb6d0d4868a..7708af95add 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -54,7 +54,6 @@ export function fallbackSanitization(content: string): string { continue } - // if value contains a colon, quote it as a scalar if (value.includes(":")) { // kilocode_change start - preserve unquoted colon values as exact strings result.push(`${key}: "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)