Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 41 additions & 22 deletions packages/opencode/src/session/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading