diff --git a/src/core/prompts/sections/__tests__/custom-instructions.test.ts b/src/core/prompts/sections/__tests__/custom-instructions.test.ts index cc9b6838b10..9cc0d233152 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.test.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.test.ts @@ -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") @@ -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") }) @@ -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") @@ -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") }) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index fff4908e554..e3df1dcf750 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -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 @@ -34,7 +35,7 @@ async function directoryExists(dirPath: string): Promise { /** * Read all text files from a directory in alphabetical order */ -async function readTextFilesFromDirectory(dirPath: string): Promise> { +async function readTextFilesFromDirectory(dirPath: string): Promise> { try { const files = await fs .readdir(dirPath, { withFileTypes: true, recursive: true }) @@ -44,13 +45,8 @@ async function readTextFilesFromDirectory(dirPath: string): Promise { 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 } @@ -58,7 +54,7 @@ async function readTextFilesFromDirectory(dirPath: string): Promise item !== null) + return fileContents.filter((item) => item !== null) } catch (err) { return [] } @@ -67,14 +63,14 @@ async function readTextFilesFromDirectory(dirPath: string): Promise): 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") ) @@ -106,12 +102,40 @@ export async function loadRuleFiles(cwd: string): Promise { 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 { + 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 { const sections = [] @@ -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) { diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index db06980175d..bcc3c6dc026 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -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 } @@ -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} diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 6af38733dd4..2520a3c5325 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -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") | [ diff --git a/src/exports/types.ts b/src/exports/types.ts index d9824ef1dbc..b234130ebad 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -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") | [ diff --git a/src/schemas/index.ts b/src/schemas/index.ts index f5cd620e2aa..b974a8d5743 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -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 + 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(), }) diff --git a/src/shared/modes.ts b/src/shared/modes.ts index 0f51b4e50fe..7d6c96d66b7 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -272,7 +272,7 @@ export async function getFullModeDetails( options.globalCustomInstructions || "", options.cwd, modeSlug, - { language: options.language }, + { language: options.language, customInstructionsPaths: baseMode.customInstructionsPaths }, ) }