diff --git a/src/services/FileManagement/FileReader.ts b/src/services/FileManagement/FileReader.ts index 0b1e9390..f19dcf4b 100644 --- a/src/services/FileManagement/FileReader.ts +++ b/src/services/FileManagement/FileReader.ts @@ -24,6 +24,17 @@ export class FileReader { } } + public async readFile( + filePath: string, + ): Promise<{ success: boolean; data?: string; error?: Error }> { + try { + const content = await this.readFileContent(filePath); + return { success: true, data: content }; + } catch (error) { + return { success: false, error }; + } + } + private async validateFilePath(filePath: string): Promise { const stats = await stat(filePath); if (!stats.isFile()) { diff --git a/src/services/LLM/actions/ReadDirectoryAction.ts b/src/services/LLM/actions/ReadDirectoryAction.ts new file mode 100644 index 00000000..56e3bd41 --- /dev/null +++ b/src/services/LLM/actions/ReadDirectoryAction.ts @@ -0,0 +1,119 @@ +// src/services/LLM/actions/ReadDirectoryAction.ts +import { DirectoryScanner } from "@services/FileManagement/DirectoryScanner"; +import { FileReader } from "@services/FileManagement/FileReader"; +import { autoInjectable } from "tsyringe"; +import { ActionTagsExtractor } from "./ActionTagsExtractor"; +import { readDirectoryActionBlueprint } from "./blueprints/readDirectoryActionBlueprint"; +import { BaseAction } from "./core/BaseAction"; +import { IActionBlueprint } from "./core/IAction"; +import { IActionResult } from "./types/ActionTypes"; + +interface ReadDirectoryParams { + directory: string; +} + +interface FileContent { + path: string; + content: string; +} + +@autoInjectable() +export class ReadDirectoryAction extends BaseAction { + constructor( + protected actionTagsExtractor: ActionTagsExtractor, + private directoryScanner: DirectoryScanner, + private fileReader: FileReader, + ) { + super(actionTagsExtractor); + } + + protected getBlueprint(): IActionBlueprint { + return readDirectoryActionBlueprint; + } + + protected parseParams(content: string): Record { + const tag = this.getBlueprint().tag; + const match = content.match(new RegExp(`<${tag}>[\\s\\S]*?<\\/${tag}>`)); + if (!match) { + this.logError("Failed to parse directory content"); + return { directory: "" }; + } + + const tagContent = match[0]; + const directory = this.actionTagsExtractor.extractTag( + tagContent, + "directory", + ); + + const getValue = (value: string | string[] | null): string => { + if (!value) return ""; + return Array.isArray(value) ? value[0]?.trim() || "" : value.trim(); + }; + + return { + directory: getValue(directory), + }; + } + + protected validateParams(params: Record): string | null { + const { directory } = params as ReadDirectoryParams; + + if (!directory) { + return "No directory provided"; + } + + return null; + } + + protected async executeInternal( + params: Record, + ): Promise { + try { + const { directory } = params as ReadDirectoryParams; + + this.logInfo(`Reading directory: ${directory}`); + + // Scan directory for files + const scanResult = await this.directoryScanner.scan(directory); + if (!scanResult.success || !scanResult.data) { + throw new Error( + `Failed to scan directory: ${scanResult.error?.message}`, + ); + } + + // Get array of file paths + if (typeof scanResult.data !== "string") { + throw new Error("scanResult.data is not a string"); + } + const filePaths = scanResult.data.split("\n").filter(Boolean); + + // Read content of each file + const fileContents: FileContent[] = []; + for (const filePath of filePaths) { + try { + const readResult = await this.fileReader.readFile(filePath); + if (readResult.success && readResult.data) { + fileContents.push({ + path: filePath, + content: readResult.data, + }); + } + } catch (error) { + this.logError(`Failed to read file ${filePath}: ${error}`); + // Continue with other files even if one fails + } + } + + if (fileContents.length === 0) { + this.logInfo("No files read successfully"); + return this.createSuccessResult([]); + } + + this.logSuccess(`Successfully read ${fileContents.length} files`); + return this.createSuccessResult(fileContents); + } catch (error) { + this.logError(`Directory read failed: ${(error as Error).message}`); + return this.createErrorResult(error as Error); + } + } +} diff --git a/src/services/LLM/actions/__tests__/ReadDirectoryAction.test.ts b/src/services/LLM/actions/__tests__/ReadDirectoryAction.test.ts new file mode 100644 index 00000000..6b2cec9d --- /dev/null +++ b/src/services/LLM/actions/__tests__/ReadDirectoryAction.test.ts @@ -0,0 +1,180 @@ +import { ReadDirectoryAction } from "../ReadDirectoryAction"; +import { ActionTagsExtractor } from "../ActionTagsExtractor"; +import { DirectoryScanner } from "@services/FileManagement/DirectoryScanner"; +import { FileReader } from "@services/FileManagement/FileReader"; +import { ConfigService } from "@services/ConfigService"; + +jest.mock("@services/FileManagement/DirectoryScanner"); +jest.mock("@services/FileManagement/FileReader"); +jest.mock("../ActionTagsExtractor"); + +describe("ReadDirectoryAction", () => { + let readDirectoryAction: ReadDirectoryAction; + let mockActionTagsExtractor: jest.Mocked; + let mockDirectoryScanner: jest.Mocked; + let mockFileReader: jest.Mocked; + let mockConfigService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Mock ActionTagsExtractor + mockActionTagsExtractor = { + extractTag: jest.fn(), + } as unknown as jest.Mocked; + + // Mock ConfigService + mockConfigService = { + getConfig: jest.fn().mockReturnValue({ + directoryScanner: { + defaultIgnore: [], + allFiles: true, + maxDepth: 4, + directoryFirst: true, + excludeDirectories: [], + }, + }), + } as unknown as jest.Mocked; + + // Mock DirectoryScanner + mockDirectoryScanner = { + scan: jest.fn(), + } as unknown as jest.Mocked; + + // Mock FileReader + mockFileReader = { + readFile: jest.fn(), + } as unknown as jest.Mocked; + + // Create instance with mocked dependencies + readDirectoryAction = new ReadDirectoryAction( + mockActionTagsExtractor, + mockDirectoryScanner, + mockFileReader, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("blueprint", () => { + it("should return the correct blueprint", () => { + const blueprint = (readDirectoryAction as any).getBlueprint(); + expect(blueprint.tag).toBe("read_directory"); + }); + }); + + describe("parameter validation", () => { + it("should fail when no directory is provided", async () => { + mockActionTagsExtractor.extractTag.mockReturnValue(null); + const actionContent = ""; + const result = await readDirectoryAction.execute(actionContent); + expect(result.success).toBe(false); + expect(result.error?.message).toContain("No directory provided"); + }); + + it("should fail when directory is empty", async () => { + const actionContent = + ""; + + mockActionTagsExtractor.extractTag.mockReturnValue(""); + + const result = await readDirectoryAction.execute(actionContent); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain("No directory provided"); + }); + }); + + describe("directory handling", () => { + it("should read directory and process files successfully", async () => { + const directory = "/test/directory"; + const filePaths = "/test/directory/file1.txt\n/test/directory/file2.txt"; + const fileContents = [ + { path: "/test/directory/file1.txt", content: "Content of file1" }, + { path: "/test/directory/file2.txt", content: "Content of file2" }, + ]; + + // Add this line to mock extractTag + mockActionTagsExtractor.extractTag.mockReturnValue(directory); + + mockDirectoryScanner.scan.mockResolvedValue({ + success: true, + data: filePaths, + }); + + mockFileReader.readFile.mockImplementation(async (filePath: string) => { + const file = fileContents.find((f) => f.path === filePath); + if (file) { + return { success: true, data: file.content }; + } + return { success: false, error: new Error("File not found") }; + }); + + const content = `${directory}`; + + const result = await readDirectoryAction.execute(content); + + expect(mockDirectoryScanner.scan).toHaveBeenCalledWith(directory); + expect(mockFileReader.readFile).toHaveBeenCalledTimes(2); + expect(result.success).toBe(true); + expect(result.data).toEqual(fileContents); + }); + + it("should handle scanner failure", async () => { + const directory = "/test/directory"; + + // Add this line to mock extractTag + mockActionTagsExtractor.extractTag.mockReturnValue(directory); + + mockDirectoryScanner.scan.mockResolvedValue({ + success: false, + error: new Error("Scan failed"), + }); + + const content = `${directory}`; + + const result = await readDirectoryAction.execute(content); + + expect(mockDirectoryScanner.scan).toHaveBeenCalledWith(directory); + expect(result.success).toBe(false); + expect(result.error?.message).toBe( + "Failed to scan directory: Scan failed", + ); + }); + + it("should handle file read failure gracefully", async () => { + const directory = "/test/directory"; + const filePaths = "/test/directory/file1.txt\n/test/directory/file2.txt"; + + // Mock extractTag to return the directory + mockActionTagsExtractor.extractTag.mockReturnValue(directory); + + mockDirectoryScanner.scan.mockResolvedValue({ + success: true, + data: filePaths, + }); + + mockFileReader.readFile.mockImplementation(async (filePath: string) => { + if (filePath === "/test/directory/file1.txt") { + return { success: true, data: "Content of file1" }; + } else { + return { success: false, error: new Error("Read failed") }; + } + }); + + const content = `${directory}`; + + const result = await readDirectoryAction.execute(content); + + expect(mockDirectoryScanner.scan).toHaveBeenCalledWith(directory); + expect(mockFileReader.readFile).toHaveBeenCalledTimes(2); + expect(result.success).toBe(true); + expect(result.data).toEqual([ + { path: "/test/directory/file1.txt", content: "Content of file1" }, + ]); + }); + }); +}); diff --git a/src/services/LLM/actions/blueprints/index.ts b/src/services/LLM/actions/blueprints/index.ts index 579e9e96..d840da88 100644 --- a/src/services/LLM/actions/blueprints/index.ts +++ b/src/services/LLM/actions/blueprints/index.ts @@ -12,6 +12,7 @@ import { gitDiffActionBlueprint } from "./gitDiffActionBlueprint"; import { gitPRDiffActionBlueprint } from "./gitPRDiffActionBlueprint"; import { moveFileActionBlueprint } from "./moveFileActionBlueprint"; import { readFileActionBlueprint } from "./readFileActionBlueprint"; +import { readDirectoryActionBlueprint } from "./readDirectoryActionBlueprint"; import { relativePathLookupActionBlueprint } from "./relativePathLookupActionBlueprint"; import { searchFileActionBlueprint, @@ -36,6 +37,7 @@ export const actionsBlueprints = { [searchFileActionBlueprint.tag]: searchFileActionBlueprint, [searchStringActionBlueprint.tag]: searchStringActionBlueprint, [writeFileActionBlueprint.tag]: writeFileActionBlueprint, + [readDirectoryActionBlueprint.tag]: readDirectoryActionBlueprint, } as const; // Type for action tags based on the blueprint map diff --git a/src/services/LLM/actions/blueprints/readDirectoryActionBlueprint.ts b/src/services/LLM/actions/blueprints/readDirectoryActionBlueprint.ts new file mode 100644 index 00000000..c6d81928 --- /dev/null +++ b/src/services/LLM/actions/blueprints/readDirectoryActionBlueprint.ts @@ -0,0 +1,22 @@ +// readDirectoryActionBlueprint.ts +import { IActionBlueprint } from "../core/IAction"; +import { ReadDirectoryAction } from "../ReadDirectoryAction"; +import { ActionPriority } from "../types/ActionPriority"; + +export const readDirectoryActionBlueprint: IActionBlueprint = { + tag: "read_directory", + class: ReadDirectoryAction, + description: "Read all files in a directory", + usageExplanation: "Use this action to read all files in a directory", + priority: ActionPriority.CRITICAL, + canRunInParallel: true, + requiresProcessing: true, + parameters: [ + { + name: "directory", + required: true, + description: "The directory path to read", + validator: (value: any) => typeof value === "string" && value.length > 0, + }, + ], +}; diff --git a/src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts b/src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts index 8d9beabb..08762a10 100644 --- a/src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts +++ b/src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts @@ -50,6 +50,10 @@ Only one action per reply. Use tags properly: ... + + directory/path + + strategy_phase diff --git a/src/services/LLM/phases/blueprints/executePhaseBlueprint.ts b/src/services/LLM/phases/blueprints/executePhaseBlueprint.ts index 3a6bb83a..7e4c65e3 100644 --- a/src/services/LLM/phases/blueprints/executePhaseBlueprint.ts +++ b/src/services/LLM/phases/blueprints/executePhaseBlueprint.ts @@ -94,6 +94,9 @@ IF previous action was NOT write_file: destination/path/here + + directory/path + diff --git a/src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts b/src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts index 298f6fc8..e96f6e97 100644 --- a/src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts +++ b/src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts @@ -75,6 +75,10 @@ REMEMBER: ONLY ONE ACTION PER REPLY!!! ... + + directory/path + + ### Other Actions