diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index ea6be536c0..eda9630827 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.96.0", + "version": "1.99.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 6657489bcf..678aef2138 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -163,6 +163,7 @@ export const globalSettingsSchema = z.object({ maxOpenTabsContext: z.number().optional(), maxWorkspaceFiles: z.number().optional(), showRooIgnoredFiles: z.boolean().optional(), + enableSubfolderRules: z.boolean().optional(), maxReadFileLine: z.number().optional(), maxImageFileSize: z.number().optional(), maxTotalImageSize: z.number().optional(), diff --git a/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts index 52098ffc30..ecafe1dc6d 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts @@ -1,15 +1,27 @@ import * as path from "path" // Use vi.hoisted to ensure mocks are available during hoisting -const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd, mockGetGlobalRooDirectory } = - vi.hoisted(() => ({ - mockHomedir: vi.fn(), - mockStat: vi.fn(), - mockReadFile: vi.fn(), - mockReaddir: vi.fn(), - mockGetRooDirectoriesForCwd: vi.fn(), - mockGetGlobalRooDirectory: vi.fn(), - })) +const { + mockHomedir, + mockStat, + mockReadFile, + mockReaddir, + mockLstat, + mockGetRooDirectoriesForCwd, + mockGetAllRooDirectoriesForCwd, + mockGetAgentsDirectoriesForCwd, + mockGetGlobalRooDirectory, +} = vi.hoisted(() => ({ + mockHomedir: vi.fn(), + mockStat: vi.fn(), + mockReadFile: vi.fn(), + mockReaddir: vi.fn(), + mockLstat: vi.fn(), + mockGetRooDirectoriesForCwd: vi.fn(), + mockGetAllRooDirectoriesForCwd: vi.fn(), + mockGetAgentsDirectoriesForCwd: vi.fn(), + mockGetGlobalRooDirectory: vi.fn(), +})) // Mock os module vi.mock("os", async (importOriginal) => ({ @@ -26,12 +38,15 @@ vi.mock("fs/promises", () => ({ stat: mockStat, readFile: mockReadFile, readdir: mockReaddir, + lstat: mockLstat, }, })) // Mock the roo-config service vi.mock("../../../../services/roo-config", () => ({ getRooDirectoriesForCwd: mockGetRooDirectoriesForCwd, + getAllRooDirectoriesForCwd: mockGetAllRooDirectoriesForCwd, + getAgentsDirectoriesForCwd: mockGetAgentsDirectoriesForCwd, getGlobalRooDirectory: mockGetGlobalRooDirectory, })) @@ -47,7 +62,13 @@ describe("custom-instructions global .roo support", () => { vi.clearAllMocks() mockHomedir.mockReturnValue(mockHomeDir) mockGetRooDirectoriesForCwd.mockReturnValue([globalRooDir, projectRooDir]) + // getAllRooDirectoriesForCwd is now async and returns the same directories by default + mockGetAllRooDirectoriesForCwd.mockResolvedValue([globalRooDir, projectRooDir]) + // getAgentsDirectoriesForCwd returns parent directories (without .roo) + mockGetAgentsDirectoriesForCwd.mockResolvedValue([mockCwd]) mockGetGlobalRooDirectory.mockReturnValue(globalRooDir) + // Default lstat to reject (file not found) + mockLstat.mockRejectedValue(new Error("ENOENT")) }) afterEach(() => { @@ -66,7 +87,11 @@ describe("custom-instructions global .roo support", () => { // Mock directory reading for global rules mockReaddir.mockResolvedValueOnce([ - { name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any, + { + name: "rules.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, ]) // Mock file reading for the rules.md file @@ -88,7 +113,11 @@ describe("custom-instructions global .roo support", () => { // Mock directory reading for project rules mockReaddir.mockResolvedValueOnce([ - { name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any, + { + name: "rules.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, ]) // Mock file reading @@ -113,8 +142,20 @@ describe("custom-instructions global .roo support", () => { // Mock directory reading mockReaddir - .mockResolvedValueOnce([{ name: "global.md", isFile: () => true, isSymbolicLink: () => false } as any]) - .mockResolvedValueOnce([{ name: "project.md", isFile: () => true, isSymbolicLink: () => false } as any]) + .mockResolvedValueOnce([ + { + name: "global.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, + ]) + .mockResolvedValueOnce([ + { + name: "project.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, + ]) // Mock file reading mockReadFile.mockResolvedValueOnce("global rule content").mockResolvedValueOnce("project rule content") @@ -183,10 +224,18 @@ describe("custom-instructions global .roo support", () => { // Mock directory reading for mode-specific rules mockReaddir .mockResolvedValueOnce([ - { name: "global-mode.md", isFile: () => true, isSymbolicLink: () => false } as any, + { + name: "global-mode.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, ]) .mockResolvedValueOnce([ - { name: "project-mode.md", isFile: () => true, isSymbolicLink: () => false } as any, + { + name: "project-mode.md", + isFile: () => true, + isSymbolicLink: () => false, + } as any, ]) // Mock file reading for mode-specific rules diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index 2754df83cb..149556abb5 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -36,7 +36,16 @@ vi.mock("path", async () => ({ .map((arg) => arg.toString().replace(/[/\\]+/g, separator)) return cleanArgs.join(separator) }), - relative: vi.fn().mockImplementation((from, to) => to), + relative: vi.fn().mockImplementation((from, to) => { + // Simple relative path computation for test scenarios + const separator = process.platform === "win32" ? "\\" : "/" + const normalizedFrom = from.replace(/[/\\]+$/, "") // Remove trailing slashes + const normalizedTo = to.replace(/[/\\]+/g, separator) + if (normalizedTo.startsWith(normalizedFrom + separator)) { + return normalizedTo.slice(normalizedFrom.length + 1) + } + return to + }), dirname: vi.fn().mockImplementation((path) => { const separator = process.platform === "win32" ? "\\" : "/" const parts = path.split(/[/\\]/) @@ -200,16 +209,16 @@ describe("loadRuleFiles", () => { }) const result = await loadRuleFiles("/fake/path") - const expectedPath1 = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt" - const expectedPath2 = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file2.txt" : "/fake/path/.roo/rules/file2.txt" - expect(result).toContain(`# Rules from ${expectedPath1}:`) + // Paths in output should be relative to cwd + const expectedRelativePath1 = process.platform === "win32" ? ".roo\\rules\\file1.txt" : ".roo/rules/file1.txt" + const expectedRelativePath2 = process.platform === "win32" ? ".roo\\rules\\file2.txt" : ".roo/rules/file2.txt" + expect(result).toContain(`# Rules from ${expectedRelativePath1}:`) expect(result).toContain("content of file1") - expect(result).toContain(`# Rules from ${expectedPath2}:`) + expect(result).toContain(`# Rules from ${expectedRelativePath2}:`) expect(result).toContain("content of file2") // We expect both checks because our new implementation checks the files again for validation + // These are the absolute paths used internally const expectedRulesDir = process.platform === "win32" ? "\\fake\\path\\.roo\\rules" : "/fake/path/.roo/rules" const expectedFile1Path = process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt" @@ -436,28 +445,25 @@ describe("loadRuleFiles", () => { const result = await loadRuleFiles("/fake/path") - // Check root file content - const expectedRootPath = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\root.txt" : "/fake/path/.roo/rules/root.txt" - const expectedNested1Path = + // Check root file content - paths in output should be relative + const expectedRelativeRootPath = process.platform === "win32" ? ".roo\\rules\\root.txt" : ".roo/rules/root.txt" + const expectedRelativeNested1Path = + process.platform === "win32" ? ".roo\\rules\\subdir\\nested1.txt" : ".roo/rules/subdir/nested1.txt" + const expectedRelativeNested2Path = process.platform === "win32" - ? "\\fake\\path\\.roo\\rules\\subdir\\nested1.txt" - : "/fake/path/.roo/rules/subdir/nested1.txt" - const expectedNested2Path = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules\\subdir\\subdir2\\nested2.txt" - : "/fake/path/.roo/rules/subdir/subdir2/nested2.txt" + ? ".roo\\rules\\subdir\\subdir2\\nested2.txt" + : ".roo/rules/subdir/subdir2/nested2.txt" - expect(result).toContain(`# Rules from ${expectedRootPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRootPath}:`) expect(result).toContain("root file content") // Check nested files content - expect(result).toContain(`# Rules from ${expectedNested1Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeNested1Path}:`) expect(result).toContain("nested file 1 content") - expect(result).toContain(`# Rules from ${expectedNested2Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeNested2Path}:`) expect(result).toContain("nested file 2 content") - // Verify correct paths were checked + // Verify correct absolute paths were checked internally const expectedRootPath2 = process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\root.txt" : "/fake/path/.roo/rules/root.txt" const expectedNested1Path2 = @@ -1055,39 +1061,34 @@ describe("addCustomInstructions", () => { { language: "es" }, ) - const expectedTestModeDir = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode" - const expectedRule1Path = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt" - : "/fake/path/.roo/rules-test-mode/rule1.txt" - const expectedRule2Path = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules-test-mode\\rule2.txt" - : "/fake/path/.roo/rules-test-mode/rule2.txt" + // Paths in output should be relative + const expectedRelativeRule1Path = + process.platform === "win32" ? ".roo\\rules-test-mode\\rule1.txt" : ".roo/rules-test-mode/rule1.txt" + const expectedRelativeRule2Path = + process.platform === "win32" ? ".roo\\rules-test-mode\\rule2.txt" : ".roo/rules-test-mode/rule2.txt" - expect(result).toContain(`# Rules from ${expectedTestModeDir}`) - expect(result).toContain(`# Rules from ${expectedRule1Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRule1Path}:`) expect(result).toContain("mode specific rule 1") - expect(result).toContain(`# Rules from ${expectedRule2Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRule2Path}:`) expect(result).toContain("mode specific rule 2") - const expectedTestModeDir2 = + // Verify absolute paths were used internally + const expectedAbsTestModeDir = process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode" - const expectedRule1Path2 = + const expectedAbsRule1Path = process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt" : "/fake/path/.roo/rules-test-mode/rule1.txt" - const expectedRule2Path2 = + const expectedAbsRule2Path = process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode\\rule2.txt" : "/fake/path/.roo/rules-test-mode/rule2.txt" - expect(statMock).toHaveBeenCalledWith(expectedTestModeDir2) - expect(statMock).toHaveBeenCalledWith(expectedRule1Path2) - expect(statMock).toHaveBeenCalledWith(expectedRule2Path2) - expect(readFileMock).toHaveBeenCalledWith(expectedRule1Path2, "utf-8") - expect(readFileMock).toHaveBeenCalledWith(expectedRule2Path2, "utf-8") + expect(statMock).toHaveBeenCalledWith(expectedAbsTestModeDir) + expect(statMock).toHaveBeenCalledWith(expectedAbsRule1Path) + expect(statMock).toHaveBeenCalledWith(expectedAbsRule2Path) + expect(readFileMock).toHaveBeenCalledWith(expectedAbsRule1Path, "utf-8") + expect(readFileMock).toHaveBeenCalledWith(expectedAbsRule2Path, "utf-8") }) it("should fall back to .roorules-test-mode when .roo/rules-test-mode/ does not exist", async () => { @@ -1190,15 +1191,11 @@ describe("addCustomInstructions", () => { "test-mode", ) - const expectedTestModeDir = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode" - const expectedRule1Path = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt" - : "/fake/path/.roo/rules-test-mode/rule1.txt" + // Paths in output should be relative + const expectedRelativeRule1Path = + process.platform === "win32" ? ".roo\\rules-test-mode\\rule1.txt" : ".roo/rules-test-mode/rule1.txt" - expect(result).toContain(`# Rules from ${expectedTestModeDir}`) - expect(result).toContain(`# Rules from ${expectedRule1Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRule1Path}:`) expect(result).toContain("mode specific rule content") expect(statCallCount).toBeGreaterThan(0) @@ -1340,31 +1337,25 @@ describe("Rules directory reading", () => { const result = await loadRuleFiles("/fake/path") - // Verify both regular file and symlink target content are included - const expectedRegularPath = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules\\regular.txt" - : "/fake/path/.roo/rules/regular.txt" - const expectedSymlinkPath = + // Verify both regular file and symlink target content are included (paths should be relative) + const expectedRelativeRegularPath = + process.platform === "win32" ? ".roo\\rules\\regular.txt" : ".roo/rules/regular.txt" + const expectedRelativeSymlinkPath = + process.platform === "win32" ? ".roo\\symlink-target.txt" : ".roo/symlink-target.txt" + const expectedRelativeSubdirPath = process.platform === "win32" - ? "\\fake\\path\\.roo\\symlink-target.txt" - : "/fake/path/.roo/symlink-target.txt" - const expectedSubdirPath = - process.platform === "win32" - ? "\\fake\\path\\.roo\\rules\\symlink-target-dir\\subdir_link.txt" - : "/fake/path/.roo/rules/symlink-target-dir/subdir_link.txt" - const expectedNestedPath = - process.platform === "win32" - ? "\\fake\\path\\.roo\\nested-symlink-target.txt" - : "/fake/path/.roo/nested-symlink-target.txt" + ? ".roo\\rules\\symlink-target-dir\\subdir_link.txt" + : ".roo/rules/symlink-target-dir/subdir_link.txt" + const expectedRelativeNestedPath = + process.platform === "win32" ? ".roo\\nested-symlink-target.txt" : ".roo/nested-symlink-target.txt" - expect(result).toContain(`# Rules from ${expectedRegularPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeRegularPath}:`) expect(result).toContain("regular file content") - expect(result).toContain(`# Rules from ${expectedSymlinkPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeSymlinkPath}:`) expect(result).toContain("symlink target content") - expect(result).toContain(`# Rules from ${expectedSubdirPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeSubdirPath}:`) expect(result).toContain("regular file content under symlink target dir") - expect(result).toContain(`# Rules from ${expectedNestedPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeNestedPath}:`) expect(result).toContain("nested symlink target content") // Verify readlink was called with the symlink path @@ -1426,18 +1417,19 @@ describe("Rules directory reading", () => { const result = await loadRuleFiles("/fake/path") - const expectedFile1Path = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt" - const expectedFile2Path = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file2.txt" : "/fake/path/.roo/rules/file2.txt" - const expectedFile3Path = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file3.txt" : "/fake/path/.roo/rules/file3.txt" + // Paths in output should be relative + const expectedRelativeFile1Path = + process.platform === "win32" ? ".roo\\rules\\file1.txt" : ".roo/rules/file1.txt" + const expectedRelativeFile2Path = + process.platform === "win32" ? ".roo\\rules\\file2.txt" : ".roo/rules/file2.txt" + const expectedRelativeFile3Path = + process.platform === "win32" ? ".roo\\rules\\file3.txt" : ".roo/rules/file3.txt" - expect(result).toContain(`# Rules from ${expectedFile1Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeFile1Path}:`) expect(result).toContain("content of file1") - expect(result).toContain(`# Rules from ${expectedFile2Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeFile2Path}:`) expect(result).toContain("content of file2") - expect(result).toContain(`# Rules from ${expectedFile3Path}:`) + expect(result).toContain(`# Rules from ${expectedRelativeFile3Path}:`) expect(result).toContain("content of file3") }) @@ -1485,17 +1477,16 @@ describe("Rules directory reading", () => { expect(alphaIndex).toBeLessThan(betaIndex) expect(betaIndex).toBeLessThan(zebraIndex) - // Verify the expected file paths are in the result - const expectedAlphaPath = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\alpha.txt" : "/fake/path/.roo/rules/alpha.txt" - const expectedBetaPath = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\Beta.txt" : "/fake/path/.roo/rules/Beta.txt" - const expectedZebraPath = - process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\zebra.txt" : "/fake/path/.roo/rules/zebra.txt" - - expect(result).toContain(`# Rules from ${expectedAlphaPath}:`) - expect(result).toContain(`# Rules from ${expectedBetaPath}:`) - expect(result).toContain(`# Rules from ${expectedZebraPath}:`) + // Verify the expected file paths are in the result (should be relative) + const expectedRelativeAlphaPath = + process.platform === "win32" ? ".roo\\rules\\alpha.txt" : ".roo/rules/alpha.txt" + const expectedRelativeBetaPath = process.platform === "win32" ? ".roo\\rules\\Beta.txt" : ".roo/rules/Beta.txt" + const expectedRelativeZebraPath = + process.platform === "win32" ? ".roo\\rules\\zebra.txt" : ".roo/rules/zebra.txt" + + expect(result).toContain(`# Rules from ${expectedRelativeAlphaPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeBetaPath}:`) + expect(result).toContain(`# Rules from ${expectedRelativeZebraPath}:`) }) it("should sort symlinks by their symlink names, not target names", async () => { diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 7cc255976b..8e8d265b5c 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -9,7 +9,12 @@ import type { SystemPromptSettings } from "../types" import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" import { LANGUAGES } from "../../../shared/language" -import { getRooDirectoriesForCwd } from "../../../services/roo-config" +import { + getRooDirectoriesForCwd, + getAllRooDirectoriesForCwd, + getAgentsDirectoriesForCwd, + getGlobalRooDirectory, +} from "../../../services/roo-config" /** * Safely read a file and return its trimmed content @@ -87,9 +92,15 @@ async function resolveSymLink( const stats = await fs.stat(resolvedTarget) if (stats.isFile()) { // For symlinks to files, store the symlink path as original and target as resolved - fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget }) + fileInfo.push({ + originalPath: symlinkPath, + resolvedPath: resolvedTarget, + }) } else if (stats.isDirectory()) { - const anotherEntries = await fs.readdir(resolvedTarget, { withFileTypes: true, recursive: true }) + const anotherEntries = await fs.readdir(resolvedTarget, { + withFileTypes: true, + recursive: true, + }) // Collect promises for recursive calls within the directory const directoryPromises: Promise[] = [] for (const anotherEntry of anotherEntries) { @@ -111,7 +122,10 @@ async function resolveSymLink( */ async function readTextFilesFromDirectory(dirPath: string): Promise> { try { - const entries = await fs.readdir(dirPath, { withFileTypes: true, recursive: true }) + const entries = await fs.readdir(dirPath, { + withFileTypes: true, + recursive: true, + }) // Process all entries - regular files and symlinks that might point to files // Store both original path (for sorting) and resolved path (for reading) @@ -168,32 +182,40 @@ async function readTextFilesFromDirectory(dirPath: string): Promise): string { +function formatDirectoryContent(files: Array<{ filename: string; content: string }>, cwd: string): string { if (files.length === 0) return "" return files .map((file) => { - return `# Rules from ${file.filename}:\n${file.content}` + // Compute relative path for display + const displayPath = path.relative(cwd, file.filename) + return `# Rules from ${displayPath}:\n${file.content}` }) .join("\n\n") } /** - * Load rule files from global and project-local directories - * Global rules are loaded first, then project-local rules which can override global ones + * Load rule files from global, project-local, and optionally subfolder directories + * Rules are loaded in order: global first, then project-local, then subfolders (alphabetically) + * + * @param cwd - Current working directory (project root) + * @param enableSubfolderRules - Whether to include rules from subdirectories (default: false) */ -export async function loadRuleFiles(cwd: string): Promise { +export async function loadRuleFiles(cwd: string, enableSubfolderRules: boolean = false): Promise { const rules: string[] = [] - const rooDirectories = getRooDirectoriesForCwd(cwd) + // Use recursive discovery only if enableSubfolderRules is true + const rooDirectories = enableSubfolderRules ? await getAllRooDirectoriesForCwd(cwd) : getRooDirectoriesForCwd(cwd) - // Check for .roo/rules/ directories in order (global first, then project-local) + // Check for .roo/rules/ directories in order (global, project-local, and optionally subfolders) for (const rooDir of rooDirectories) { const rulesDir = path.join(rooDir, "rules") if (await directoryExists(rulesDir)) { const files = await readTextFilesFromDirectory(rulesDir) if (files.length > 0) { - const content = formatDirectoryContent(rulesDir, files) + const content = formatDirectoryContent(files, cwd) rules.push(content) } } @@ -201,7 +223,7 @@ export async function loadRuleFiles(cwd: string): Promise { // If we found rules in .roo/rules/ directories, return them if (rules.length > 0) { - return "\n" + rules.join("\n\n") + return "\n# Rules from .roo directories:\n\n" + rules.join("\n\n") } // Fall back to existing behavior for legacy .roorules/.clinerules files @@ -218,16 +240,24 @@ export async function loadRuleFiles(cwd: string): Promise { } /** - * Load AGENTS.md or AGENT.md file from the project root if it exists + * Load AGENTS.md or AGENT.md file from a specific directory * Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility + * + * @param directory - Directory to check for AGENTS.md + * @param showPath - Whether to include the directory path in the header + * @param cwd - Current working directory for computing relative paths (optional) */ -async function loadAgentRulesFile(cwd: string): Promise { +async function loadAgentRulesFileFromDirectory( + directory: string, + showPath: boolean = false, + cwd?: string, +): Promise { // Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative) const filenames = ["AGENTS.md", "AGENT.md"] for (const filename of filenames) { try { - const agentPath = path.join(cwd, filename) + const agentPath = path.join(directory, filename) let resolvedPath = agentPath // Check if file exists and handle symlinks @@ -235,7 +265,10 @@ async function loadAgentRulesFile(cwd: string): Promise { const stats = await fs.lstat(agentPath) if (stats.isSymbolicLink()) { // Create a temporary fileInfo array to use with resolveSymLink - const fileInfo: Array<{ originalPath: string; resolvedPath: string }> = [] + const fileInfo: Array<{ + originalPath: string + resolvedPath: string + }> = [] // Use the existing resolveSymLink function to handle symlink resolution await resolveSymLink(agentPath, fileInfo, 0) @@ -253,7 +286,12 @@ async function loadAgentRulesFile(cwd: string): Promise { // Read the content from the resolved path const content = await safeReadFile(resolvedPath) if (content) { - return `# Agent Rules Standard (${filename}):\n${content}` + // Compute relative path for display if cwd is provided + const displayPath = cwd ? path.relative(cwd, directory) : directory + const header = showPath + ? `# Agent Rules Standard (${filename}) from ${displayPath}:` + : `# Agent Rules Standard (${filename}):` + return `${header}\n${content}` } } catch (err) { // Silently ignore errors - agent rules files are optional @@ -262,6 +300,51 @@ async function loadAgentRulesFile(cwd: string): Promise { return "" } +/** + * Load AGENTS.md or AGENT.md file from the project root if it exists + * Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility + * + * @deprecated Use loadAllAgentRulesFiles for loading from all directories + */ +async function loadAgentRulesFile(cwd: string): Promise { + return loadAgentRulesFileFromDirectory(cwd, false, cwd) +} + +/** + * Load all AGENTS.md files from project root and optionally subdirectories with .roo folders + * Returns combined content with clear path headers for each file + * + * @param cwd - Current working directory (project root) + * @param enableSubfolderRules - Whether to include AGENTS.md from subdirectories (default: false) + * @returns Combined AGENTS.md content from all locations + */ +async function loadAllAgentRulesFiles(cwd: string, enableSubfolderRules: boolean = false): Promise { + const agentRules: string[] = [] + + // When subfolder rules are disabled, only load from root + if (!enableSubfolderRules) { + const content = await loadAgentRulesFileFromDirectory(cwd, false, cwd) + if (content && content.trim()) { + agentRules.push(content.trim()) + } + return agentRules.join("\n\n") + } + + // When enabled, load from root and all subdirectories with .roo folders + const directories = await getAgentsDirectoriesForCwd(cwd) + + for (const directory of directories) { + // Show path for all directories except the root + const showPath = directory !== cwd + const content = await loadAgentRulesFileFromDirectory(directory, showPath, cwd) + if (content && content.trim()) { + agentRules.push(content.trim()) + } + } + + return agentRules.join("\n\n") +} + export async function addCustomInstructions( modeCustomInstructions: string, globalCustomInstructions: string, @@ -276,6 +359,9 @@ export async function addCustomInstructions( ): Promise { const sections = [] + // Get the enableSubfolderRules setting (default: false) + const enableSubfolderRules = options.settings?.enableSubfolderRules ?? false + // Load mode-specific rules if mode is provided let modeRuleContent = "" let usedRuleFile = "" @@ -310,15 +396,18 @@ export async function addCustomInstructions( if (mode) { const modeRules: string[] = [] - const rooDirectories = getRooDirectoriesForCwd(cwd) + // Use recursive discovery only if enableSubfolderRules is true + const rooDirectories = enableSubfolderRules + ? await getAllRooDirectoriesForCwd(cwd) + : getRooDirectoriesForCwd(cwd) - // Check for .roo/rules-${mode}/ directories in order (global first, then project-local) + // Check for .roo/rules-${mode}/ directories in order (global, project-local, and optionally subfolders) for (const rooDir of rooDirectories) { const modeRulesDir = path.join(rooDir, `rules-${mode}`) if (await directoryExists(modeRulesDir)) { const files = await readTextFilesFromDirectory(modeRulesDir) if (files.length > 0) { - const content = formatDirectoryContent(modeRulesDir, files) + const content = formatDirectoryContent(files, cwd) modeRules.push(content) } } @@ -379,15 +468,16 @@ export async function addCustomInstructions( } // Add AGENTS.md content if enabled (default: true) + // Load from root and optionally subdirectories with .roo folders based on enableSubfolderRules setting if (options.settings?.useAgentRules !== false) { - const agentRulesContent = await loadAgentRulesFile(cwd) + const agentRulesContent = await loadAllAgentRulesFiles(cwd, enableSubfolderRules) if (agentRulesContent && agentRulesContent.trim()) { rules.push(agentRulesContent.trim()) } } // Add generic rules - const genericRuleContent = await loadRuleFiles(cwd) + const genericRuleContent = await loadRuleFiles(cwd, enableSubfolderRules) if (genericRuleContent && genericRuleContent.trim()) { rules.push(genericRuleContent.trim()) } diff --git a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts new file mode 100644 index 0000000000..92c2c9f5db --- /dev/null +++ b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts @@ -0,0 +1,158 @@ +import type OpenAI from "openai" +import { createReadFileTool, type ReadFileToolOptions } from "../read_file" + +// Helper type to access function tools +type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } + +// Helper to get function definition from tool +const getFunctionDef = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function + +describe("createReadFileTool", () => { + describe("maxConcurrentFileReads documentation", () => { + it("should include default maxConcurrentFileReads limit (5) in description", () => { + const tool = createReadFileTool() + const description = getFunctionDef(tool).description + + expect(description).toContain("maximum of 5 files") + expect(description).toContain("If you need to read more files, use multiple sequential read_file requests") + }) + + it("should include custom maxConcurrentFileReads limit in description", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 3 }) + const description = getFunctionDef(tool).description + + expect(description).toContain("maximum of 3 files") + expect(description).toContain("within 3-file limit") + }) + + it("should indicate single file reads only when maxConcurrentFileReads is 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 1 }) + const description = getFunctionDef(tool).description + + expect(description).toContain("Multiple file reads are currently disabled") + expect(description).toContain("only read one file at a time") + expect(description).not.toContain("Example multiple files") + }) + + it("should use singular 'Read a file' in base description when maxConcurrentFileReads is 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 1 }) + const description = getFunctionDef(tool).description + + expect(description).toMatch(/^Read a file/) + expect(description).not.toContain("Read one or more files") + }) + + it("should use plural 'Read one or more files' in base description when maxConcurrentFileReads is > 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 5 }) + const description = getFunctionDef(tool).description + + expect(description).toMatch(/^Read one or more files/) + }) + + it("should not show multiple files example when maxConcurrentFileReads is 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 1, partialReadsEnabled: true }) + const description = getFunctionDef(tool).description + + expect(description).not.toContain("Example multiple files") + }) + + it("should show multiple files example when maxConcurrentFileReads is > 1", () => { + const tool = createReadFileTool({ maxConcurrentFileReads: 5, partialReadsEnabled: true }) + const description = getFunctionDef(tool).description + + expect(description).toContain("Example multiple files") + }) + }) + + describe("partialReadsEnabled option", () => { + it("should include line_ranges in description when partialReadsEnabled is true", () => { + const tool = createReadFileTool({ partialReadsEnabled: true }) + const description = getFunctionDef(tool).description + + expect(description).toContain("line_ranges") + expect(description).toContain("Example with line ranges") + }) + + it("should not include line_ranges in description when partialReadsEnabled is false", () => { + const tool = createReadFileTool({ partialReadsEnabled: false }) + const description = getFunctionDef(tool).description + + expect(description).not.toContain("line_ranges") + expect(description).not.toContain("Example with line ranges") + }) + + it("should include line_ranges parameter in schema when partialReadsEnabled is true", () => { + const tool = createReadFileTool({ partialReadsEnabled: true }) + const schema = getFunctionDef(tool).parameters as any + + expect(schema.properties.files.items.properties).toHaveProperty("line_ranges") + }) + + it("should not include line_ranges parameter in schema when partialReadsEnabled is false", () => { + const tool = createReadFileTool({ partialReadsEnabled: false }) + const schema = getFunctionDef(tool).parameters as any + + expect(schema.properties.files.items.properties).not.toHaveProperty("line_ranges") + }) + }) + + describe("combined options", () => { + it("should correctly combine low maxConcurrentFileReads with partialReadsEnabled", () => { + const tool = createReadFileTool({ + maxConcurrentFileReads: 2, + partialReadsEnabled: true, + }) + const description = getFunctionDef(tool).description + + expect(description).toContain("maximum of 2 files") + expect(description).toContain("line_ranges") + expect(description).toContain("within 2-file limit") + }) + + it("should correctly handle maxConcurrentFileReads of 1 with partialReadsEnabled false", () => { + const tool = createReadFileTool({ + maxConcurrentFileReads: 1, + partialReadsEnabled: false, + }) + const description = getFunctionDef(tool).description + + expect(description).toContain("only read one file at a time") + expect(description).not.toContain("line_ranges") + expect(description).not.toContain("Example multiple files") + }) + }) + + describe("tool structure", () => { + it("should have correct tool name", () => { + const tool = createReadFileTool() + + expect(getFunctionDef(tool).name).toBe("read_file") + }) + + it("should be a function type tool", () => { + const tool = createReadFileTool() + + expect(tool.type).toBe("function") + }) + + it("should have strict mode enabled", () => { + const tool = createReadFileTool() + + expect(getFunctionDef(tool).strict).toBe(true) + }) + + it("should require files parameter", () => { + const tool = createReadFileTool() + const schema = getFunctionDef(tool).parameters as any + + expect(schema.required).toContain("files") + }) + + it("should require path in file objects", () => { + const tool = createReadFileTool({ partialReadsEnabled: false }) + const schema = getFunctionDef(tool).parameters as any + + expect(schema.properties.files.items.required).toContain("path") + }) + }) +}) diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 1bae8021a7..50c120e308 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -12,7 +12,7 @@ import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" import listFiles from "./list_files" import newTask from "./new_task" -import { createReadFileTool } from "./read_file" +import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" import searchAndReplace from "./search_and_replace" import searchReplace from "./search_replace" @@ -24,14 +24,32 @@ import writeToFile from "./write_to_file" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" +export type { ReadFileToolOptions } from "./read_file" + +/** + * Options for customizing the native tools array. + */ +export interface NativeToolsOptions { + /** Whether to include line_ranges support in read_file tool (default: true) */ + partialReadsEnabled?: boolean + /** Maximum number of files that can be read in a single read_file request (default: 5) */ + maxConcurrentFileReads?: number +} /** * Get native tools array, optionally customizing based on settings. * - * @param partialReadsEnabled - Whether to include line_ranges support in read_file tool (default: true) + * @param options - Configuration options for the tools * @returns Array of native tool definitions */ -export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool[] { +export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] { + const { partialReadsEnabled = true, maxConcurrentFileReads = 5 } = options + + const readFileOptions: ReadFileToolOptions = { + partialReadsEnabled, + maxConcurrentFileReads, + } + return [ accessMcpResource, apply_diff, @@ -46,7 +64,7 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat generateImage, listFiles, newTask, - createReadFileTool(partialReadsEnabled), + createReadFileTool(readFileOptions), runSlashCommand, searchAndReplace, searchReplace, @@ -59,4 +77,4 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat } // Backward compatibility: export default tools with line ranges enabled -export const nativeTools = getNativeTools(true) +export const nativeTools = getNativeTools() diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index bf43f26c8a..cfb0b8bbe1 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -1,20 +1,36 @@ import type OpenAI from "openai" -const READ_FILE_BASE_DESCRIPTION = `Read one or more files and return their contents with line numbers for diffing or discussion.` - const READ_FILE_SUPPORTS_NOTE = `Supports text extraction from PDF and DOCX files, but may not handle other binary files properly.` +/** + * Options for creating the read_file tool definition. + */ +export interface ReadFileToolOptions { + /** Whether to include line_ranges parameter (default: true) */ + partialReadsEnabled?: boolean + /** Maximum number of files that can be read in a single request (default: 5) */ + maxConcurrentFileReads?: number +} + /** * Creates the read_file tool definition, optionally including line_ranges support * based on whether partial reads are enabled. * - * @param partialReadsEnabled - Whether to include line_ranges parameter + * @param options - Configuration options for the tool * @returns Native tool definition for read_file */ -export function createReadFileTool(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool { +export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool { + const { partialReadsEnabled = true, maxConcurrentFileReads = 5 } = options + const isMultipleReadsEnabled = maxConcurrentFileReads > 1 + + // Build description intro with concurrent reads limit message + const descriptionIntro = isMultipleReadsEnabled + ? `Read one or more files and return their contents with line numbers for diffing or discussion. IMPORTANT: You can read a maximum of ${maxConcurrentFileReads} files in a single request. If you need to read more files, use multiple sequential read_file requests. ` + : "Read a file and return its contents with line numbers for diffing or discussion. IMPORTANT: Multiple file reads are currently disabled. You can only read one file at a time. " + const baseDescription = - READ_FILE_BASE_DESCRIPTION + - " Structure: { files: [{ path: 'relative/path.ts'" + + descriptionIntro + + "Structure: { files: [{ path: 'relative/path.ts'" + (partialReadsEnabled ? ", line_ranges: [[1, 50], [100, 150]]" : "") + " }] }. " + "The 'path' is required and relative to workspace. " @@ -26,9 +42,13 @@ export function createReadFileTool(partialReadsEnabled: boolean = true): OpenAI. const examples = partialReadsEnabled ? "Example single file: { files: [{ path: 'src/app.ts' }] }. " + "Example with line ranges: { files: [{ path: 'src/app.ts', line_ranges: [[1, 50], [100, 150]] }] }. " + - "Example multiple files: { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }" + (isMultipleReadsEnabled + ? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }` + : "") : "Example single file: { files: [{ path: 'src/app.ts' }] }. " + - "Example multiple files: { files: [{ path: 'file1.ts' }, { path: 'file2.ts' }] }" + (isMultipleReadsEnabled + ? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts' }, { path: 'file2.ts' }] }` + : "") const description = baseDescription + optionalRangesDescription + READ_FILE_SUPPORTS_NOTE + " " + examples @@ -87,4 +107,4 @@ export function createReadFileTool(partialReadsEnabled: boolean = true): OpenAI. } satisfies OpenAI.Chat.ChatCompletionTool } -export const read_file = createReadFileTool(false) +export const read_file = createReadFileTool({ partialReadsEnabled: false }) diff --git a/src/core/prompts/types.ts b/src/core/prompts/types.ts index 30cc4e4700..f14785ed0b 100644 --- a/src/core/prompts/types.ts +++ b/src/core/prompts/types.ts @@ -8,6 +8,8 @@ export interface SystemPromptSettings { todoListEnabled: boolean browserToolEnabled?: boolean useAgentRules: boolean + /** When true, recursively discover and load .roo/rules from subdirectories */ + enableSubfolderRules?: boolean newTaskRequireTodos: boolean terminalShellIntegrationDisabled?: boolean toolProtocol?: ToolProtocol diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a058c043e5..59520c7fba 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3758,6 +3758,7 @@ export class Task extends EventEmitter implements TaskLike { maxReadFileLine, apiConfiguration, terminalShellIntegrationDisabled, + enableSubfolderRules, } = state ?? {} return await (async () => { @@ -3811,6 +3812,7 @@ export class Task extends EventEmitter implements TaskLike { browserToolEnabled: browserToolEnabled ?? true, useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, + enableSubfolderRules: enableSubfolderRules ?? false, newTaskRequireTodos: vscode.workspace .getConfiguration(Package.name) .get("newTaskRequireTodos", false), @@ -4181,6 +4183,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, maxReadFileLine: state?.maxReadFileLine ?? -1, + maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, browserToolEnabled: state?.browserToolEnabled ?? true, modelInfo, diffEnabled: this.diffEnabled, diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index d8f381377d..228949aa40 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -19,6 +19,7 @@ interface BuildToolsOptions { experiments: Record | undefined apiConfiguration: ProviderSettings | undefined maxReadFileLine: number + maxConcurrentFileReads: number browserToolEnabled: boolean modelInfo?: ModelInfo diffEnabled: boolean @@ -40,6 +41,7 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise experiments, apiConfiguration, maxReadFileLine, + maxConcurrentFileReads, browserToolEnabled, modelInfo, diffEnabled, @@ -62,8 +64,11 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise // Determine if partial reads are enabled based on maxReadFileLine setting. const partialReadsEnabled = maxReadFileLine !== -1 - // Build native tools with dynamic read_file tool based on partialReadsEnabled. - const nativeTools = getNativeTools(partialReadsEnabled) + // Build native tools with dynamic read_file tool based on settings. + const nativeTools = getNativeTools({ + partialReadsEnabled, + maxConcurrentFileReads, + }) // Filter native tools based on mode restrictions. const filteredNativeTools = filterNativeToolsForMode( diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 93bd321687..8e0dcbe8de 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2043,6 +2043,7 @@ export class ClineProvider browserToolEnabled, telemetrySetting, showRooIgnoredFiles, + enableSubfolderRules, language, maxReadFileLine, maxReadCharacterLimit, @@ -2199,6 +2200,7 @@ export class ClineProvider machineId, showRooIgnoredFiles: showRooIgnoredFiles ?? false, language: language ?? formatLanguage(await defaultLang()), + enableSubfolderRules: enableSubfolderRules ?? false, renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? 500, maxReadCharacterLimit: maxReadCharacterLimit ?? DEFAULT_FILE_READ_CHARACTER_LIMIT, @@ -2458,6 +2460,7 @@ export class ClineProvider showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, maxReadFileLine: stateValues.maxReadFileLine ?? 500, maxReadCharacterLimit: stateValues.maxReadCharacterLimit ?? DEFAULT_FILE_READ_CHARACTER_LIMIT, + enableSubfolderRules: stateValues.enableSubfolderRules ?? false, maxImageFileSize: stateValues.maxImageFileSize ?? 5, maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index a4c9c1f8b6..c465837a82 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -606,6 +606,7 @@ describe("ClineProvider", () => { // showRooIgnoredFiles: true, telemetrySetting: "disabled", showRooIgnoredFiles: false, + enableSubfolderRules: false, renderContext: "sidebar", maxReadFileLine: 500, maxImageFileSize: 5, diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 7fb42200e8..7bdb909c90 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -28,6 +28,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web maxReadFileLine, maxConcurrentFileReads, terminalShellIntegrationDisabled, + enableSubfolderRules, } = await provider.getState() // Check experiment to determine which diff strategy to use @@ -95,6 +96,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web maxConcurrentFileReads: maxConcurrentFileReads ?? 5, todoListEnabled: apiConfiguration?.todoListEnabled ?? true, useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, + enableSubfolderRules: enableSubfolderRules ?? false, newTaskRequireTodos: vscode.workspace .getConfiguration(Package.name) .get("newTaskRequireTodos", false), diff --git a/src/services/roo-config/__tests__/index.spec.ts b/src/services/roo-config/__tests__/index.spec.ts index 90b911a665..cf0359069c 100644 --- a/src/services/roo-config/__tests__/index.spec.ts +++ b/src/services/roo-config/__tests__/index.spec.ts @@ -1,10 +1,11 @@ import * as path from "path" // Use vi.hoisted to ensure mocks are available during hoisting -const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({ +const { mockStat, mockReadFile, mockHomedir, mockExecuteRipgrep } = vi.hoisted(() => ({ mockStat: vi.fn(), mockReadFile: vi.fn(), mockHomedir: vi.fn(), + mockExecuteRipgrep: vi.fn(), })) // Mock fs/promises module @@ -21,6 +22,11 @@ vi.mock("os", async (importOriginal) => ({ homedir: mockHomedir, })) +// Mock executeRipgrep from search service +vi.mock("../../search/file-search", () => ({ + executeRipgrep: mockExecuteRipgrep, +})) + import { getGlobalRooDirectory, getProjectRooDirectoryForCwd, @@ -28,6 +34,9 @@ import { fileExists, readFileIfExists, getRooDirectoriesForCwd, + getAllRooDirectoriesForCwd, + getAgentsDirectoriesForCwd, + discoverSubfolderRooDirectories, loadConfiguration, } from "../index" @@ -302,4 +311,188 @@ describe("RooConfigService", () => { expect(mockReadFile).toHaveBeenCalledWith(path.join("/project/path", ".roo", "rules/rules.md"), "utf-8") }) }) + + describe("discoverSubfolderRooDirectories", () => { + it("should return empty array when no subfolder .roo directories found", async () => { + mockExecuteRipgrep.mockResolvedValue([]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([]) + }) + + it("should discover .roo directories from subfolders", async () => { + // Find any file inside .roo directories + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "package-a/.roo/rules/rule.md", type: "file" }, + { path: "package-b/.roo/rules-code/rule.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([ + path.join("/project/path", "package-a", ".roo"), + path.join("/project/path", "package-b", ".roo"), + ]) + }) + + it("should sort discovered directories alphabetically", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "zebra/.roo/rules/rule.md", type: "file" }, + { path: "apple/.roo/rules/rule.md", type: "file" }, + { path: "mango/.roo/rules/rule.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([ + path.join("/project/path", "apple", ".roo"), + path.join("/project/path", "mango", ".roo"), + path.join("/project/path", "zebra", ".roo"), + ]) + }) + + it("should exclude root .roo directory", async () => { + // This would match the root .roo, which should be excluded + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: ".roo/rules/rule.md", type: "file" }, // This is root - should be excluded + { path: "subfolder/.roo/rules/rule.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + // Should only include subfolder, not root + expect(result).toEqual([path.join("/project/path", "subfolder", ".roo")]) + }) + + it("should handle nested subdirectories", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "packages/core/.roo/rules/rule.md", type: "file" }, + { path: "packages/utils/.roo/rules-code/rule.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([ + path.join("/project/path", "packages/core", ".roo"), + path.join("/project/path", "packages/utils", ".roo"), + ]) + }) + + it("should return empty array on ripgrep error", async () => { + mockExecuteRipgrep.mockRejectedValue(new Error("ripgrep failed")) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([]) + }) + + it("should deduplicate .roo directories from multiple files", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "package-a/.roo/rules/rule1.md", type: "file" }, + { path: "package-a/.roo/rules/rule2.md", type: "file" }, + { path: "package-a/.roo/rules-code/rule3.md", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + // Should only include package-a/.roo once + expect(result).toEqual([path.join("/project/path", "package-a", ".roo")]) + }) + + it("should discover .roo directories with any content", async () => { + // Should find .roo directories regardless of what's inside them + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "package-a/.roo/rules/rule.md", type: "file" }, + { path: "package-b/.roo/rules-code/code-rule.md", type: "file" }, + { path: "package-c/.roo/rules-architect/arch-rule.md", type: "file" }, + { path: "package-d/.roo/config/settings.json", type: "file" }, + ]) + + const result = await discoverSubfolderRooDirectories("/project/path") + + expect(result).toEqual([ + path.join("/project/path", "package-a", ".roo"), + path.join("/project/path", "package-b", ".roo"), + path.join("/project/path", "package-c", ".roo"), + path.join("/project/path", "package-d", ".roo"), + ]) + }) + }) + + describe("getAllRooDirectoriesForCwd", () => { + it("should return global, project, and subfolder directories", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([{ path: "subfolder/.roo/rules/rule.md", type: "file" }]) + + const result = await getAllRooDirectoriesForCwd("/project/path") + + expect(result).toEqual([ + path.join("/mock/home", ".roo"), // global + path.join("/project/path", ".roo"), // project + path.join("/project/path", "subfolder", ".roo"), // subfolder + ]) + }) + + it("should return only global and project when no subfolders", async () => { + mockExecuteRipgrep.mockResolvedValue([]) + + const result = await getAllRooDirectoriesForCwd("/project/path") + + expect(result).toEqual([path.join("/mock/home", ".roo"), path.join("/project/path", ".roo")]) + }) + + it("should maintain order: global, project, subfolders (alphabetically)", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "zebra/.roo/rules/rule.md", type: "file" }, + { path: "apple/.roo/rules/rule.md", type: "file" }, + ]) + + const result = await getAllRooDirectoriesForCwd("/project/path") + + expect(result).toEqual([ + path.join("/mock/home", ".roo"), // global first + path.join("/project/path", ".roo"), // project second + path.join("/project/path", "apple", ".roo"), // subfolders alphabetically + path.join("/project/path", "zebra", ".roo"), + ]) + }) + }) + + describe("getAgentsDirectoriesForCwd", () => { + it("should return root directory and parent directories of subfolder .roo dirs", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([{ path: "package-a/.roo/rules/rule.md", type: "file" }]) + + const result = await getAgentsDirectoriesForCwd("/project/path") + + expect(result).toEqual([ + "/project/path", // root + path.join("/project/path", "package-a"), // parent of .roo + ]) + }) + + it("should always include root even when no subfolders", async () => { + mockExecuteRipgrep.mockResolvedValue([]) + + const result = await getAgentsDirectoriesForCwd("/project/path") + + expect(result).toEqual(["/project/path"]) + }) + + it("should include multiple subfolder parent directories", async () => { + mockExecuteRipgrep.mockResolvedValueOnce([ + { path: "package-a/.roo/rules/rule.md", type: "file" }, + { path: "package-b/.roo/rules-code/rule.md", type: "file" }, + { path: "packages/core/.roo/rules/rule.md", type: "file" }, + ]) + + const result = await getAgentsDirectoriesForCwd("/project/path") + + expect(result).toEqual([ + "/project/path", + path.join("/project/path", "package-a"), + path.join("/project/path", "package-b"), + path.join("/project/path", "packages/core"), + ]) + }) + }) }) diff --git a/src/services/roo-config/index.ts b/src/services/roo-config/index.ts index 9805f56e81..de49d24d15 100644 --- a/src/services/roo-config/index.ts +++ b/src/services/roo-config/index.ts @@ -115,6 +115,89 @@ export async function readFileIfExists(filePath: string): Promise } } +/** + * Discovers all .roo directories in subdirectories of the workspace + * + * @param cwd - Current working directory (workspace root) + * @returns Array of absolute paths to .roo directories found in subdirectories, + * sorted alphabetically. Does not include the root .roo directory. + * + * @example + * ```typescript + * const subfolderRoos = await discoverSubfolderRooDirectories('/Users/john/monorepo') + * // Returns: + * // [ + * // '/Users/john/monorepo/package-a/.roo', + * // '/Users/john/monorepo/package-b/.roo', + * // '/Users/john/monorepo/packages/shared/.roo' + * // ] + * ``` + * + * @example Directory structure: + * ``` + * /Users/john/monorepo/ + * ├── .roo/ # Root .roo (NOT included - use getProjectRooDirectoryForCwd) + * ├── package-a/ + * │ └── .roo/ # Included + * │ └── rules/ + * ├── package-b/ + * │ └── .roo/ # Included + * │ └── rules-code/ + * └── packages/ + * └── shared/ + * └── .roo/ # Included (nested) + * └── rules/ + * ``` + */ +export async function discoverSubfolderRooDirectories(cwd: string): Promise { + try { + // Dynamic import to avoid vscode dependency at module load time + // This is necessary because file-search.ts imports vscode, which is not + // available in the webview context + const { executeRipgrep } = await import("../search/file-search") + + // Use ripgrep to find any file inside any .roo directory + // This efficiently discovers all .roo folders regardless of their content + const args = [ + "--files", + "--hidden", + "--follow", + "-g", + "**/.roo/**", + "-g", + "!node_modules/**", + "-g", + "!.git/**", + cwd, + ] + + const results = await executeRipgrep({ args, workspacePath: cwd }) + + // Extract unique .roo directory paths + const rooDirs = new Set() + const rootRooDir = path.join(cwd, ".roo") + + for (const result of results) { + // Match paths like "subfolder/.roo/anything" or "subfolder/nested/.roo/anything" + // Handle both forward slashes (Unix) and backslashes (Windows) + const match = result.path.match(/^(.+?)[/\\]\.roo[/\\]/) + if (match) { + const rooDir = path.join(cwd, match[1], ".roo") + // Exclude the root .roo directory (already handled by getProjectRooDirectoryForCwd) + if (rooDir !== rootRooDir) { + rooDirs.add(rooDir) + } + } + } + + // Return sorted alphabetically + return Array.from(rooDirs).sort() + } catch (error) { + // If discovery fails (e.g., ripgrep not available), return empty array + return [] + } +} + /** * Gets the ordered list of .roo directories to check (global first, then project-local) * @@ -164,6 +247,71 @@ export function getRooDirectoriesForCwd(cwd: string, ignoreOpenspec = false): st return directories } +/** + * Gets the ordered list of all .roo directories including subdirectories + * + * @param cwd - Current working directory (project path) + * @returns Array of directory paths in order: [global, project-local, ...subfolders (alphabetically)] + * + * @example + * ```typescript + * // For a monorepo at /Users/john/monorepo with .roo in subfolders + * const directories = await getAllRooDirectoriesForCwd('/Users/john/monorepo') + * // Returns: + * // [ + * // '/Users/john/.roo', // Global directory + * // '/Users/john/monorepo/.roo', // Project-local directory + * // '/Users/john/monorepo/package-a/.roo', // Subfolder (alphabetical) + * // '/Users/john/monorepo/package-b/.roo' // Subfolder (alphabetical) + * // ] + * ``` + */ +export async function getAllRooDirectoriesForCwd(cwd: string): Promise { + const directories: string[] = [] + + // Add global directory first + directories.push(getGlobalRooDirectory()) + + // Add project-local directory second + directories.push(getProjectRooDirectoryForCwd(cwd)) + + // Discover and add subfolder .roo directories + const subfolderDirs = await discoverSubfolderRooDirectories(cwd) + directories.push(...subfolderDirs) + + return directories +} + +/** + * Gets parent directories containing .roo folders, in order from root to subfolders + * + * @param cwd - Current working directory (project path) + * @returns Array of parent directory paths (not .roo paths) containing AGENTS.md or .roo + * + * @example + * ```typescript + * const dirs = await getAgentsDirectoriesForCwd('/Users/john/monorepo') + * // Returns: ['/Users/john/monorepo', '/Users/john/monorepo/package-a', ...] + * ``` + */ +export async function getAgentsDirectoriesForCwd(cwd: string): Promise { + const directories: string[] = [] + + // Always include the root directory + directories.push(cwd) + + // Get all subfolder .roo directories + const subfolderRooDirs = await discoverSubfolderRooDirectories(cwd) + + // Extract parent directories (remove .roo from path) + for (const rooDir of subfolderRooDirs) { + const parentDir = path.dirname(rooDir) + directories.push(parentDir) + } + + return directories +} + /** * Loads configuration from multiple .roo directories with project overriding global * diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index a55719a4ac..418524eced 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -347,6 +347,7 @@ export type ExtensionState = Pick< maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings + enableSubfolderRules: boolean // Whether to load rules from subdirectories maxReadFileLine: number // Maximum number of lines to read from a file before truncating maxImageFileSize: number // Maximum size of image files to process in MB maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB diff --git a/src/shared/string-extensions.d.ts b/src/shared/string-extensions.d.ts new file mode 100644 index 0000000000..aad87e4867 --- /dev/null +++ b/src/shared/string-extensions.d.ts @@ -0,0 +1,25 @@ +/** + * Global string extensions declaration. + * This file provides type declarations for String.prototype extensions + * that are used across the codebase. + * + * The actual implementation is in src/utils/path.ts. + * + * This separate declaration file is necessary because the webview-ui package + * includes ../src/shared in its tsconfig.json but not ../src/utils/path.ts. + * Without this file, the webview-ui compilation would fail when processing + * files that use the toPosix() method. + */ +declare global { + interface String { + /** + * Convert a path string to POSIX format (forward slashes). + * Extended-Length Paths in Windows (\\?\) are preserved. + * @returns The path with backslashes converted to forward slashes + */ + toPosix(): string + } +} + +// This export is needed to make this file a module +export {} diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 8a3da5cf1e..d9641c8cd7 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -21,6 +21,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxOpenTabsContext: number maxWorkspaceFiles: number showRooIgnoredFiles?: boolean + enableSubfolderRules?: boolean maxReadFileLine?: number maxReadCharacterLimit?: number maxImageFileSize?: number @@ -39,6 +40,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxOpenTabsContext" | "maxWorkspaceFiles" | "showRooIgnoredFiles" + | "enableSubfolderRules" | "maxReadFileLine" | "maxReadCharacterLimit" | "maxImageFileSize" @@ -62,6 +64,7 @@ export const ContextManagementSettings = ({ maxOpenTabsContext, maxWorkspaceFiles, showRooIgnoredFiles, + enableSubfolderRules, setCachedStateField, maxReadFileLine, maxReadCharacterLimit, @@ -227,6 +230,20 @@ export const ContextManagementSettings = ({ {t("settings:contextManagement.maxReadCharacter.description")} +
+ setCachedStateField("enableSubfolderRules", e.target.checked)} + data-testid="enable-subfolder-rules-checkbox"> + + +
+ {t("settings:contextManagement.enableSubfolderRules.description")} +
+
+
{t("settings:contextManagement.maxReadFile.label")} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 98a6686f69..0e05d1f651 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -198,6 +198,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZdotdir, writeDelayMs, showRooIgnoredFiles, + enableSubfolderRules, remoteBrowserEnabled, maxReadFileLine, maxImageFileSize, @@ -405,6 +406,7 @@ const SettingsView = forwardRef(({ onDone, t maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500), showRooIgnoredFiles: showRooIgnoredFiles ?? true, + enableSubfolderRules: enableSubfolderRules ?? false, maxReadFileLine: maxReadFileLine ?? -1, maxImageFileSize: maxImageFileSize ?? 5, maxTotalImageSize: maxTotalImageSize ?? 20, @@ -823,6 +825,7 @@ const SettingsView = forwardRef(({ onDone, t maxOpenTabsContext={maxOpenTabsContext} maxWorkspaceFiles={maxWorkspaceFiles ?? 300} showRooIgnoredFiles={showRooIgnoredFiles} + enableSubfolderRules={enableSubfolderRules} maxReadFileLine={maxReadFileLine} maxReadCharacterLimit={maxReadCharacterLimit} zgsmCodebaseIndexEnabled={zgsmCodebaseIndexEnabled ?? true} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 006fe2205e..d9d185609e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -81,6 +81,7 @@ export interface ExtensionStateContextType extends ExtensionState { setAlwaysAllowSubtasks: (value: boolean) => void setBrowserToolEnabled: (value: boolean) => void setShowRooIgnoredFiles: (value: boolean) => void + setEnableSubfolderRules: (value: boolean) => void setShowAnnouncement: (value: boolean) => void setAllowedCommands: (value: string[]) => void setDeniedCommands: (value: string[]) => void @@ -259,6 +260,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode browserToolEnabled: true, telemetrySetting: "disabled", showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior). + enableSubfolderRules: false, // Default to disabled - must be enabled to load rules from subdirectories renderContext: "sidebar", maxReadFileLine: 500, // Default max read file line limit maxImageFileSize: 5, // Default max image file size in MB @@ -633,6 +635,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })), setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })), setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })), + setEnableSubfolderRules: (value) => setState((prevState) => ({ ...prevState, enableSubfolderRules: value })), setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })), setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })), setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 13dcf8e505..d5897c859d 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -198,6 +198,7 @@ describe("mergeExtensionState", () => { apiConfiguration: { providerId: "openrouter" } as ProviderSettings, telemetrySetting: "disabled", showRooIgnoredFiles: true, + enableSubfolderRules: false, renderContext: "sidebar", maxReadFileLine: 500, cloudUserInfo: null, diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 12f96010c0..00dc9fdcc0 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -733,6 +733,10 @@ "maxGitStatusFiles": { "label": "Git status max files", "description": "Maximum number of file entries to include in git status context. Set to 0 to disable. Branch info is always shown when > 0." + }, + "enableSubfolderRules": { + "label": "Enable subfolder rules", + "description": "Recursively discover and load .roo/rules and AGENTS.md files from subdirectories. Useful for monorepos with per-package rules." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index e7c568c1f5..c92965e9c3 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -686,6 +686,10 @@ "maxGitStatusFiles": { "label": "Git 状态最大文件数", "description": "git状态上下文中包含的最大文件条目数。设为0禁用。分支信息和提交在>0时始终显示。" + }, + "enableSubfolderRules": { + "label": "启用子文件夹规则", + "description": "递归发现并加载子目录中的 .roo/rules 和 AGENTS.md 文件。适用于具有每包规则的 monorepo。" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 7ef7beb79e..3d90845fe3 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -686,6 +686,10 @@ "maxGitStatusFiles": { "label": "Git 狀態最大檔案數", "description": "git狀態上下文中包含的最大檔案條目數。設為0禁用。分支資訊和提交在>0時始終顯示。" + }, + "enableSubfolderRules": { + "label": "啟用子資料夾規則", + "description": "遞迴發現並載入子目錄中的 .roo/rules 和 AGENTS.md 檔案。適用於具有每包規則的 monorepo。" } }, "terminal": { diff --git a/webview-ui/vite.config.ts b/webview-ui/vite.config.ts index 45ae6d6b1e..cb543cd627 100644 --- a/webview-ui/vite.config.ts +++ b/webview-ui/vite.config.ts @@ -105,6 +105,10 @@ export default defineConfig(({ mode }) => { // Use a single combined CSS bundle so both webviews share styles cssCodeSplit: false, rollupOptions: { + // Externalize vscode module - it's imported by file-search.ts which is + // dynamically imported by roo-config/index.ts, but should never be bundled + // in the webview since it's not available in the browser context + external: ["vscode"], input: { index: resolve(__dirname, "index.html"), "browser-panel": resolve(__dirname, "browser-panel.html"),