Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { loadRuleFiles, addCustomInstructions } from "../custom-instructions"
import fs from "fs/promises"
import path from "path"
import { PathLike } from "fs"
import { parentPort } from "worker_threads"

// Mock fs/promises
jest.mock("fs/promises")
Expand Down Expand Up @@ -154,8 +155,6 @@ describe("loadRuleFiles", () => {
expect(result).toContain("# Rules from /fake/path/.roo/rules/file2.txt:")
expect(result).toContain("content of file2")

expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules/file1.txt")
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules/file2.txt")
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/file1.txt", "utf-8")
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/file2.txt", "utf-8")
})
Expand Down Expand Up @@ -265,11 +264,6 @@ describe("loadRuleFiles", () => {
expect(result).toContain("# Rules from /fake/path/.roo/rules/subdir/subdir2/nested2.txt:")
expect(result).toContain("nested file 2 content")

// Verify correct paths were checked
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules/root.txt")
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules/subdir/nested1.txt")
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules/subdir/subdir2/nested2.txt")

// Verify files were read with correct paths
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/root.txt", "utf-8")
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/subdir/nested1.txt", "utf-8")
Expand Down Expand Up @@ -430,8 +424,6 @@ describe("addCustomInstructions", () => {
expect(result).toContain("# Rules from /fake/path/.roo/rules-test-mode/rule2.txt:")
expect(result).toContain("mode specific rule 2")

expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules-test-mode/rule1.txt")
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules-test-mode/rule2.txt")
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules-test-mode/rule1.txt", "utf-8")
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules-test-mode/rule2.txt", "utf-8")
})
Expand Down
70 changes: 56 additions & 14 deletions src/core/prompts/sections/custom-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "fs/promises"
import path from "path"

import { LANGUAGES, isLanguage } from "../../../shared/language"
import { CustomInstructionsPathsConfig } from "../../../schemas"

/**
* Safely read a file and return its trimmed content
Expand Down Expand Up @@ -34,7 +35,7 @@ async function directoryExists(dirPath: string): Promise<boolean> {
/**
* Read all text files from a directory in alphabetical order
*/
async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ filename: string; content: string }>> {
async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ fullPath: string; content: string }>> {
try {
const files = await fs
.readdir(dirPath, { withFileTypes: true, recursive: true })
Expand All @@ -44,21 +45,16 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
const fileContents = await Promise.all(
files.map(async (file) => {
try {
// Check if it's a file (not a directory)
const stats = await fs.stat(file)
if (stats.isFile()) {
const content = await safeReadFile(file)
return { filename: file, content }
}
return null
const content = await safeReadFile(file)
return { fullPath: file, content }
} catch (err) {
return null
}
}),
)

// Filter out null values (directories or failed reads)
return fileContents.filter((item): item is { filename: string; content: string } => item !== null)
return fileContents.filter((item) => item !== null)
} catch (err) {
return []
}
Expand All @@ -67,14 +63,14 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
/**
* Format content from multiple files with filenames as headers
*/
function formatDirectoryContent(dirPath: string, files: Array<{ filename: string; content: string }>): string {
function formatDirectoryContent(dirPath: string, files: Array<{ fullPath: string; content: string }>): string {
if (files.length === 0) return ""

return (
"\n\n" +
files
.map((file) => {
return `# Rules from ${file.filename}:\n${file.content}`
return `# Rules from ${file.fullPath}:\n${file.content}`
})
.join("\n\n")
)
Expand Down Expand Up @@ -106,12 +102,40 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
return ""
}

/**
* Load rules from a directory or file.
* If the path is a directory, it will read all files in that directory recursively.
* If the path is a file, it will read that file.
* @param fullPath - The full path to the directory or file.
* @returns An array of rules, each representing the content of a rule file.
*/
export async function loadRulesInPath(fullPath: string): Promise<string[]> {
const rules: string[] = []
const stats = await fs.lstat(fullPath)
if (stats.isDirectory()) {
const ruleFiles = await readTextFilesFromDirectory(fullPath)
if (ruleFiles.length > 0) {
rules.push(...ruleFiles.map((ruleFile) => `# Rules from ${ruleFile.fullPath}:\n${ruleFile.content}`))
}
} else {
const content = await safeReadFile(fullPath)
if (content) {
rules.push(`# Rules from ${fullPath}:\n${content}`)
}
}
return rules
}

export async function addCustomInstructions(
modeCustomInstructions: string,
globalCustomInstructions: string,
cwd: string,
mode: string,
options: { language?: string; rooIgnoreInstructions?: string } = {},
options: {
language?: string
rooIgnoreInstructions?: string
customInstructionsPaths?: CustomInstructionsPathsConfig[]
} = {},
): Promise<string> {
const sections = []

Expand Down Expand Up @@ -176,14 +200,32 @@ export async function addCustomInstructions(
}
}

// Load custom instructions from paths if provided
if (options.customInstructionsPaths) {
const customInstructionsFullPaths = options.customInstructionsPaths.map((customPath) => {
return typeof customPath === "string"
? path.isAbsolute(customPath)
? customPath
: path.join(cwd, customPath)
: customPath.isAbsolute || path.isAbsolute(customPath.path)
? customPath.path
: path.join(cwd, customPath.path)
})
// TODO: Consider to use glob pattern
for (const customInstructionsFullPath of customInstructionsFullPaths) {
const loadedRules = await loadRulesInPath(customInstructionsFullPath)
rules.push(...loadedRules)
}
}

if (options.rooIgnoreInstructions) {
rules.push(options.rooIgnoreInstructions)
}

// Add generic rules
const genericRuleContent = await loadRuleFiles(cwd)
if (genericRuleContent && genericRuleContent.trim()) {
rules.push(genericRuleContent.trim())
if (genericRuleContent) {
rules.push(genericRuleContent)
}

if (rules.length > 0) {
Expand Down
8 changes: 6 additions & 2 deletions src/core/prompts/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ ${getSystemInfoSection(cwd, mode, customModeConfigs)}

${getObjectiveSection()}

${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions })}`
${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions, customInstructionsPaths: modeConfig.customInstructionsPaths })}`

return basePrompt
}
Expand Down Expand Up @@ -141,7 +141,11 @@ export const SYSTEM_PROMPT = async (
globalCustomInstructions || "",
cwd,
mode,
{ language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions },
{
language: language ?? formatLanguage(vscode.env.language),
rooIgnoreInstructions,
customInstructionsPaths: currentMode.customInstructionsPaths,
},
)
// For file-based prompts, don't include the tool sections
return `${roleDefinition}
Expand Down
9 changes: 9 additions & 0 deletions src/exports/roo-code.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,15 @@ type GlobalSettings = {
name: string
roleDefinition: string
customInstructions?: string | undefined
customInstructionsPaths?:
| (
| {
path: string
isAbsolute?: boolean | undefined
}
| string
)[]
| undefined
groups: (
| ("read" | "edit" | "browser" | "command" | "mcp" | "modes")
| [
Expand Down
9 changes: 9 additions & 0 deletions src/exports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,15 @@ type GlobalSettings = {
name: string
roleDefinition: string
customInstructions?: string | undefined
customInstructionsPaths?:
| (
| {
path: string
isAbsolute?: boolean | undefined
}
| string
)[]
| undefined
groups: (
| ("read" | "edit" | "browser" | "command" | "mcp" | "modes")
| [
Expand Down
10 changes: 10 additions & 0 deletions src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,21 @@ const groupEntryArraySchema = z.array(groupEntrySchema).refine(
{ message: "Duplicate groups are not allowed" },
)

export const customInstructionsPathsConfigSchema = z.union([
z.object({
path: z.string(),
isAbsolute: z.boolean().optional(),
}),
z.string(),
])
export type CustomInstructionsPathsConfig = z.infer<typeof customInstructionsPathsConfigSchema>

export const modeConfigSchema = z.object({
slug: z.string().regex(/^[a-zA-Z0-9-]+$/, "Slug must contain only letters numbers and dashes"),
name: z.string().min(1, "Name is required"),
roleDefinition: z.string().min(1, "Role definition is required"),
customInstructions: z.string().optional(),
customInstructionsPaths: z.array(customInstructionsPathsConfigSchema).optional(),
groups: groupEntryArraySchema,
source: z.enum(["global", "project"]).optional(),
})
Expand Down
2 changes: 1 addition & 1 deletion src/shared/modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export async function getFullModeDetails(
options.globalCustomInstructions || "",
options.cwd,
modeSlug,
{ language: options.language },
{ language: options.language, customInstructionsPaths: baseMode.customInstructionsPaths },
)
}

Expand Down