Skip to content
Draft
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.5",
"packageManager": "bun@^1.3.5",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
Expand Down
26 changes: 25 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ import {
RGBA,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
import type {
AssistantMessage,
Part,
ToolPart,
UserMessage,
TextPart,
ReasoningPart,
RulesPart,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
Expand Down Expand Up @@ -1150,6 +1158,7 @@ function UserMessage(props: {
const local = useLocal()
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const rules = createMemo(() => props.parts.filter((x) => x.type === "rules") as RulesPart[])
const sync = useSync()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
Expand Down Expand Up @@ -1203,6 +1212,7 @@ function UserMessage(props: {
</For>
</box>
</Show>
<For each={rules()}>{(part) => <RulesPart part={part} message={props.message} last={false} />}</For>
<Show
when={queued()}
fallback={
Expand Down Expand Up @@ -1318,6 +1328,20 @@ const PART_MAPPING = {
text: TextPart,
tool: ToolPart,
reasoning: ReasoningPart,
rules: RulesPart,
}

function RulesPart(props: { last: boolean; part: RulesPart; message: UserMessage | AssistantMessage }) {
const { theme } = useTheme()
const paths = () => (props.part.files ?? []).map((f) => normalizePath(f)).join(", ")

return (
<box paddingLeft={3} marginTop={1}>
<text fg={theme.textMuted}>
<span style={{ bold: true }}>✱</span> rules applied ({paths()})
</text>
</box>
)
}

function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"

function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
return path.relative(process.cwd(), input) || "."
}
return input
}

export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
const { theme } = useTheme()
Expand All @@ -25,6 +33,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
diff: true,
todo: true,
lsp: true,
rules: true,
})

// Sort MCP servers alphabetically for consistent display order
Expand Down Expand Up @@ -257,6 +266,34 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
</box>
</Show>
<Show when={session().rules && session().rules!.length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => (session().rules?.length ?? 0) > 2 && setExpanded("rules", !expanded.rules)}
>
<Show when={(session().rules?.length ?? 0) > 2}>
<text fg={theme.text}>{expanded.rules ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Rules</b>
</text>
</box>
<Show when={(session().rules?.length ?? 0) <= 2 || expanded.rules}>
<For each={session().rules}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
•{" "}
{item.startsWith(sync.data.path.directory)
? item.slice(sync.data.path.directory.length).replace(/^\//, "")
: item}
</text>
)}
</For>
</Show>
</box>
</Show>
</box>
</scrollbox>

Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,22 @@ export namespace Config {
.record(z.string(), Command)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
subdirectoryRules: z
.object({
enabled: z.boolean().optional().default(false),
patterns: z.string().array().optional().describe("List of glob patterns to match rule files"),
exact: z
.boolean()
.optional()
.default(false)
.describe("If true, only load rules from the exact file directory"),
})
.strict()
.meta({
ref: "SubdirectoryRulesConfig",
})
.optional()
.describe("Configuration for subdirectory rule discovery"),
watcher: z
.object({
ignore: z.array(z.string()).optional(),
Expand Down
159 changes: 159 additions & 0 deletions packages/opencode/src/config/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import path from "path"
import { ConfigMarkdown } from "./markdown"
import { Config } from "./config"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { existsSync } from "fs"
import { SystemPrompt } from "@/session/system"

export interface Rule {
filePath: string
paths?: string[]
content: string
}

export namespace Rules {
async function parse(filePath: string): Promise<Rule> {
const md = await ConfigMarkdown.parse(filePath)
const frontmatter = md.data as Record<string, unknown> | undefined

const paths = frontmatter?.paths
let pathsArray: string[] | undefined

if (paths !== undefined) {
if (typeof paths === "string") {
pathsArray = [paths]
} else if (Array.isArray(paths)) {
pathsArray = paths.filter((p) => typeof p === "string") as string[]
}
}

return {
filePath,
paths: pathsArray,
content: md.content.trim(),
}
}

function sortPatternsBySpecificity(patterns: string[]): string[] {
const positives: string[] = []
const negatives: string[] = []

for (const pattern of patterns) {
if (pattern.startsWith("!")) {
negatives.push(pattern)
} else {
positives.push(pattern)
}
}

positives.sort((a, b) => {
const aDepth = a.split("/").length
const bDepth = b.split("/").length
if (aDepth !== bDepth) return aDepth - bDepth

const aWildcards = (a.match(/\*\*/g) || []).length + (a.match(/\*/g) || []).length
const bWildcards = (b.match(/\*\*/g) || []).length + (b.match(/\*/g) || []).length
if (aWildcards !== bWildcards) return bWildcards - aWildcards

return a.localeCompare(b)
})

return [...positives, ...negatives]
}

function matchesPattern(filepath: string, pattern: string): boolean {
return new Bun.Glob(pattern).match(filepath)
}

export function matchRulesForFile(rules: Rule[], filepath: string): string[] {
const matchedContents: string[] = []
const seenContents = new Set<string>()

const relative = path.isAbsolute(filepath) ? path.relative(Instance.worktree, filepath) : filepath

const rulesWithPaths = rules.filter((r) => r.paths && r.paths.length > 0)
const globalRules = rules.filter((r) => !r.paths || r.paths.length === 0)

for (const rule of globalRules) {
if (!seenContents.has(rule.content)) {
seenContents.add(rule.content)
matchedContents.push(rule.content)
}
}

for (const rule of rulesWithPaths) {
const patterns = sortPatternsBySpecificity(rule.paths ?? [])

let matched = false
let excluded = false

for (const pattern of patterns) {
if (pattern.startsWith("!")) {
if (matchesPattern(relative, pattern.slice(1))) {
excluded = true
break
}
} else {
if (matchesPattern(relative, pattern)) {
matched = true
}
}
}

if (matched && !excluded) {
if (!seenContents.has(rule.content)) {
seenContents.add(rule.content)
matchedContents.push(rule.content)
}
}
}

return matchedContents
}

export async function loadForFile(filepath: string): Promise<Rule[]> {
const config = await Config.get()
const rulesConfig = config.subdirectoryRules

if (!rulesConfig?.enabled) return []

const patterns = rulesConfig?.patterns ?? ["**/AGENTS.md"]
const exact = rulesConfig?.exact ?? false
const directories = await Array.fromAsync(
Filesystem.up({
targets: ["."],
start: path.dirname(filepath),
stop: Instance.worktree,
}),
).then((dirs) => {
// Exclude root directory as it's already in the system prompt
const filtered = dirs.filter((dir) => dir !== Instance.worktree)
return exact ? (filtered.length > 0 ? [filtered[0]] : []) : filtered.reverse()
})

const results: Rule[] = []
const seen = new Set<string>()
const worktree = Instance.worktree
const rootExclusions = new Set(SystemPrompt.LOCAL_RULE_FILES.map((f) => path.join(worktree, f)))

for (const dir of directories) {
if (!dir || !existsSync(dir)) continue
// Ensure we don't leak outside the worktree if it's set to root
if (worktree === "/" && !dir.startsWith(Instance.directory)) continue
for (const pattern of patterns) {
const glob = new Bun.Glob(pattern)
for await (const file of glob.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
})) {
if (rootExclusions.has(file) || seen.has(file)) continue
seen.add(file)
results.push(await parse(file))
}
}
}
return results
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { SessionRules } from "./rules"

export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
Expand Down Expand Up @@ -83,6 +84,7 @@ export namespace SessionCompaction {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
await Session.updatePart(part)
SessionRules.onCallPruned(input.sessionID, part.callID)
}
}
log.info("pruned", { count: toPrune.length })
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { SessionRules } from "./rules"

import { Storage } from "../storage/storage"
import { Log } from "../util/log"
Expand Down Expand Up @@ -76,6 +77,7 @@ export namespace Session {
diff: z.string().optional(),
})
.optional(),
rules: z.string().array().optional(),
})
.meta({
ref: "Session",
Expand Down Expand Up @@ -341,6 +343,7 @@ export namespace Session {
Bus.publish(Event.Deleted, {
info: session,
})
await SessionRules.clearSessionRules(sessionID)
} catch (e) {
log.error(e)
}
Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ export namespace MessageV2 {
})
export type CompactionPart = z.infer<typeof CompactionPart>

export const RulesPart = PartBase.extend({
type: z.literal("rules"),
rules: z.string().array(),
files: z.string().array().optional(),
}).meta({
ref: "RulesPart",
})
export type RulesPart = z.infer<typeof RulesPart>

export const SubtaskPart = PartBase.extend({
type: z.literal("subtask"),
prompt: z.string(),
Expand Down Expand Up @@ -339,6 +348,7 @@ export namespace MessageV2 {
PatchPart,
AgentPart,
RetryPart,
RulesPart,
CompactionPart,
])
.meta({
Expand Down Expand Up @@ -472,6 +482,12 @@ export namespace MessageV2 {
text: "The following tool was executed by the user",
})
}
if (part.type === "rules") {
userMessage.parts.push({
type: "text",
text: `<instructions>\n${part.rules.join("\n\n")}\n</instructions>`,
})
}
}
}

Expand Down
Loading