mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge pull request #8349 from mmospanenko/feature/7507-context
feat(config): add {file:...} syntax support for agent markdown prompts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import path from "path" // kilocode_change
|
||||
import { Exit, Schema, SchemaGetter } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
@@ -13,6 +14,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
|
||||
// kilocode_change start
|
||||
import { KilocodeConfig } from "@/kilocode/config/config"
|
||||
import type { Warning } from "./config"
|
||||
@@ -162,11 +164,21 @@ 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",
|
||||
dir: path.dirname(item),
|
||||
source: item,
|
||||
missing: "empty",
|
||||
escapeJson: false,
|
||||
})
|
||||
const config = {
|
||||
name,
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
||||
export const SHELL_REGEX = /!`([^`]+)`/g
|
||||
@@ -53,10 +54,10 @@ export function fallbackSanitization(content: string): string {
|
||||
continue
|
||||
}
|
||||
|
||||
// if value contains a colon, convert to block scalar
|
||||
if (value.includes(":")) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -70,12 +71,16 @@ 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
|
||||
return md
|
||||
} catch {
|
||||
try {
|
||||
return matter(fallbackSanitization(template))
|
||||
const md = matter(fallbackSanitization(template))
|
||||
md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change
|
||||
return md
|
||||
} catch (err) {
|
||||
throw new FrontmatterError(
|
||||
{
|
||||
@@ -86,8 +91,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({
|
||||
@@ -95,5 +102,16 @@ export const FrontmatterError = NamedError.create(
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
export * as ConfigMarkdown from "./markdown"
|
||||
// kilocode_change start - export helpers as namespace object
|
||||
export const ConfigMarkdown = {
|
||||
FILE_REGEX,
|
||||
SHELL_REGEX,
|
||||
files,
|
||||
shell,
|
||||
fallbackSanitization,
|
||||
parse,
|
||||
FrontmatterError,
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
@@ -19,6 +19,7 @@ type ParseSource =
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
escapeJson?: boolean // kilocode_change
|
||||
}
|
||||
|
||||
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 // kilocode_change
|
||||
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 // kilocode_change
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
|
||||
@@ -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) : "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { KilocodeMarkdown } from "../config/markdown"
|
||||
|
||||
export namespace KilocodeInstruction {
|
||||
export function content(text: string, item: string) {
|
||||
return KilocodeMarkdown.substitute(text, item)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 type { MessageV2 } from "./message-v2"
|
||||
import type { MessageID } from "./schema"
|
||||
|
||||
@@ -88,7 +89,8 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
|
||||
const read = Effect.fnUntraced(function* (filepath: string) {
|
||||
return 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
|
||||
})
|
||||
|
||||
const fetch = Effect.fnUntraced(function* (url: string) {
|
||||
|
||||
@@ -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<Config.Info> = {
|
||||
},
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "node:path"
|
||||
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 { MessageID } from "../../../src/session/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { provideTmpdirInstance } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
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", () => {
|
||||
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"),
|
||||
)
|
||||
yield* write(path.join(dir, "subdir", "nested", "file.ts"), "const value = 1")
|
||||
|
||||
const svc = yield* Instruction.Service
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(dir, "subdir", "nested", "file.ts"),
|
||||
MessageID.ascending(),
|
||||
)
|
||||
|
||||
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:")
|
||||
delete process.env.KILO_INSTRUCTION_TEST
|
||||
}).pipe(Effect.provide(layer(dir))),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user