Skip to content

Commit 7507bde

Browse files
committed
feat: add context filtering for instruction files
Add context.exclude config option to filter which instruction files are automatically loaded. - Add ContextMap utility returning Effect directly - Integrate in instruction.ts to filter paths and skip ignored files - Integrate in skill/index.ts to filter external skill directories Example config: { "context": { "exclude": ["AGENTS.md", "CLAUDE.md"] } }
1 parent eac50f9 commit 7507bde

File tree

4 files changed

+102
-1
lines changed

4 files changed

+102
-1
lines changed

packages/opencode/src/config/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,19 @@ export namespace Config {
10571057
},
10581058
),
10591059
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
1060+
context: z
1061+
.object({
1062+
exclude: z
1063+
.array(z.string())
1064+
.optional()
1065+
.describe("Glob patterns for instruction files or directories to exclude from automatic loading"),
1066+
include: z
1067+
.array(z.string())
1068+
.optional()
1069+
.describe("Glob patterns for additional instruction files to include"),
1070+
})
1071+
.optional()
1072+
.describe("Control which instruction files are automatically loaded"),
10601073
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
10611074
permission: Permission.optional(),
10621075
tools: z.record(z.string(), z.boolean()).optional(),

packages/opencode/src/session/instruction.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { withTransientReadRetry } from "@/util/effect-http-client"
1111
import { Global } from "../global"
1212
import { Instance } from "../project/instance"
1313
import { Log } from "../util/log"
14+
import { ContextMap } from "../util/context-map"
1415
import type { MessageV2 } from "./message-v2"
1516
import type { MessageID } from "./schema"
1617

@@ -158,7 +159,8 @@ export namespace Instruction {
158159
}
159160
}
160161

161-
return paths
162+
// Filter paths based on context.exclude config
163+
return yield* ContextMap.filterPaths(paths)
162164
})
163165

164166
const system = Effect.fn("Instruction.system")(function* () {
@@ -206,6 +208,13 @@ export namespace Instruction {
206208
continue
207209
}
208210

211+
// Check if file should be excluded via context.exclude config
212+
const ignored = yield* ContextMap.isIgnored(found)
213+
if (ignored) {
214+
current = path.dirname(current)
215+
continue
216+
}
217+
209218
let set = s.claims.get(messageID)
210219
if (!set) {
211220
set = new Set()

packages/opencode/src/skill/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ConfigMarkdown } from "../config/markdown"
1717
import { Glob } from "../util/glob"
1818
import { Log } from "../util/log"
1919
import { Discovery } from "./discovery"
20+
import { ContextMap } from "../util/context-map"
2021

2122
export namespace Skill {
2223
const log = Log.create({ service: "skill" })
@@ -145,6 +146,7 @@ export namespace Skill {
145146
) {
146147
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
147148
for (const dir of EXTERNAL_DIRS) {
149+
if (yield* ContextMap.isExternalDirIgnored(dir)) continue
148150
const root = path.join(Global.Path.home, dir)
149151
if (!(yield* fsys.isDir(root))) continue
150152
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
@@ -155,6 +157,8 @@ export namespace Skill {
155157
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
156158

157159
for (const root of upDirs) {
160+
const dirName = path.basename(root)
161+
if (yield* ContextMap.isExternalDirIgnored(dirName)) continue
158162
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
159163
}
160164
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import path from "path"
2+
import { Effect } from "effect"
3+
import { minimatch } from "minimatch"
4+
import { Config } from "../config/config"
5+
import { Instance } from "../project/instance"
6+
7+
export namespace ContextMap {
8+
function excludePatterns(): Effect.Effect<string[], never, never> {
9+
return Effect.promise(() => Config.get().then((config) => config.context?.exclude ?? [])).pipe(
10+
Effect.catch(() => Effect.succeed([])),
11+
)
12+
}
13+
14+
function includePatterns(): Effect.Effect<string[], never, never> {
15+
return Effect.promise(() => Config.get().then((config) => config.context?.include ?? [])).pipe(
16+
Effect.catch(() => Effect.succeed([])),
17+
)
18+
}
19+
20+
function matches(filepath: string, patterns: string[]): boolean {
21+
const basename = path.basename(filepath)
22+
const relative = path.relative(Instance.directory, filepath)
23+
24+
for (const pattern of patterns) {
25+
if (minimatch(basename, pattern)) return true
26+
if (minimatch(relative, pattern)) return true
27+
if (minimatch(filepath, pattern)) return true
28+
}
29+
return false
30+
}
31+
32+
export function isIgnored(filepath: string): Effect.Effect<boolean, never, never> {
33+
return excludePatterns().pipe(
34+
Effect.map((patterns) => {
35+
if (patterns.length === 0) return false
36+
return matches(filepath, patterns)
37+
}),
38+
)
39+
}
40+
41+
export function filterPaths(paths: Set<string>): Effect.Effect<Set<string>, never, never> {
42+
return excludePatterns().pipe(
43+
Effect.map((patterns) => {
44+
if (patterns.length === 0) return paths
45+
const result = new Set<string>()
46+
for (const p of paths) {
47+
if (!matches(p, patterns)) {
48+
result.add(p)
49+
}
50+
}
51+
return result
52+
}),
53+
)
54+
}
55+
56+
export function isExternalDirIgnored(dirName: string): Effect.Effect<boolean, never, never> {
57+
return excludePatterns().pipe(
58+
Effect.map((patterns) => {
59+
if (patterns.length === 0) return false
60+
for (const pattern of patterns) {
61+
if (minimatch(dirName, pattern)) return true
62+
const segments = pattern.split("/")
63+
for (const segment of segments) {
64+
if (segment !== "*" && minimatch(dirName, segment)) return true
65+
}
66+
}
67+
return false
68+
}),
69+
)
70+
}
71+
72+
export function contextPatterns(): Effect.Effect<string[], never, never> {
73+
return includePatterns()
74+
}
75+
}

0 commit comments

Comments
 (0)