diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 86f73d0fd23..010ca3c7062 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -17,16 +17,26 @@ const FILES = [ "CONTEXT.md", // deprecated ] +function variant(file: string) { + return file.replace(/\.md$/, ".local.md") +} + function globalFiles() { - const files = [] + const groups: string[][] = [] if (Flag.OPENCODE_CONFIG_DIR) { - files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) + groups.push([ + path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"), + path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.local.md"), + ]) } - files.push(path.join(Global.Path.config, "AGENTS.md")) + groups.push([path.join(Global.Path.config, "AGENTS.md"), path.join(Global.Path.config, "AGENTS.local.md")]) if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { - files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) + groups.push([ + path.join(os.homedir(), ".claude", "CLAUDE.md"), + path.join(os.homedir(), ".claude", "CLAUDE.local.md"), + ]) } - return files + return groups } async function resolveRelative(instruction: string): Promise { @@ -76,20 +86,24 @@ export namespace InstructionPrompt { if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { for (const file of FILES) { const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree) - if (matches.length > 0) { - matches.forEach((p) => { - paths.add(path.resolve(p)) - }) + const locals = await Filesystem.findUp(variant(file), Instance.directory, Instance.worktree) + if (matches.length > 0 || locals.length > 0) { + for (const p of matches) paths.add(path.resolve(p)) + for (const p of locals) paths.add(path.resolve(p)) break } } } - for (const file of globalFiles()) { - if (await Filesystem.exists(file)) { - paths.add(path.resolve(file)) - break + for (const group of globalFiles()) { + let found = false + for (const file of group) { + if (await Filesystem.exists(file)) { + paths.add(path.resolve(file)) + found = true + } } + if (found) break } if (config.instructions) { @@ -160,9 +174,14 @@ export namespace InstructionPrompt { export async function find(dir: string) { for (const file of FILES) { - const filepath = path.resolve(path.join(dir, file)) - if (await Filesystem.exists(filepath)) return filepath + const base = path.resolve(path.join(dir, file)) + const local = path.resolve(path.join(dir, variant(file))) + const results: string[] = [] + if (await Filesystem.exists(base)) results.push(base) + if (await Filesystem.exists(local)) results.push(local) + if (results.length > 0) return results } + return [] } export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) { @@ -175,13 +194,13 @@ export namespace InstructionPrompt { const root = path.resolve(Instance.directory) while (current.startsWith(root) && current !== root) { - const found = await find(current) - - if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) { - claim(messageID, found) - const content = await Filesystem.readText(found).catch(() => undefined) - if (content) { - results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content }) + for (const found of await find(current)) { + if (found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) { + claim(messageID, found) + const content = await Filesystem.readText(found).catch(() => undefined) + if (content) { + results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content }) + } } } current = path.dirname(current) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index e0bf94a9500..d76e70fb139 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -70,6 +70,85 @@ describe("InstructionPrompt.resolve", () => { }) }) +describe("InstructionPrompt .local.md variants", () => { + test("loads AGENTS.local.md alongside AGENTS.md in systemPaths", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Shared Instructions") + await Bun.write(path.join(dir, "AGENTS.local.md"), "# Personal Instructions") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const paths = await InstructionPrompt.systemPaths() + expect(paths.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(tmp.path, "AGENTS.local.md"))).toBe(true) + }, + }) + }) + + test("loads AGENTS.local.md even without AGENTS.md", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.local.md"), "# Personal Only") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const paths = await InstructionPrompt.systemPaths() + expect(paths.has(path.join(tmp.path, "AGENTS.local.md"))).toBe(true) + }, + }) + }) + + test("resolve returns both AGENTS.md and AGENTS.local.md from subdirectory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Shared") + await Bun.write(path.join(dir, "subdir", "AGENTS.local.md"), "# Subdir Personal") + await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const results = await InstructionPrompt.resolve( + [], + path.join(tmp.path, "subdir", "nested", "file.ts"), + "test-local-1", + ) + expect(results.length).toBe(2) + const paths = results.map((r) => r.filepath) + expect(paths).toContain(path.join(tmp.path, "subdir", "AGENTS.md")) + expect(paths).toContain(path.join(tmp.path, "subdir", "AGENTS.local.md")) + }, + }) + }) + + test("resolve returns only AGENTS.local.md when AGENTS.md is absent", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "AGENTS.local.md"), "# Subdir Personal") + await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const results = await InstructionPrompt.resolve( + [], + path.join(tmp.path, "subdir", "nested", "file.ts"), + "test-local-2", + ) + expect(results.length).toBe(1) + expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.local.md")) + }, + }) + }) +}) + describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { let originalConfigDir: string | undefined