diff --git a/src/services/LLM/actions/ListDirectoryFilesAction.ts b/src/services/LLM/actions/ListDirectoryFilesAction.ts new file mode 100644 index 00000000..7b2e036d --- /dev/null +++ b/src/services/LLM/actions/ListDirectoryFilesAction.ts @@ -0,0 +1,109 @@ +import { DirectoryScanner } from "@/services/FileManagement/DirectoryScanner"; +import { DebugLogger } from "@/services/logging/DebugLogger"; +import fs from "fs"; +import path from "path"; +import { autoInjectable } from "tsyringe"; +import { ActionTagsExtractor } from "./ActionTagsExtractor"; +import { BaseAction } from "./core/BaseAction"; +import { IActionBlueprint } from "./core/IAction"; +import { IActionResult } from "./types/ActionTypes"; + +interface IListDirectoryFilesParams { + path: string | string[]; + recursive?: boolean; +} + +@autoInjectable() +export class ListDirectoryFilesAction extends BaseAction { + protected getBlueprint(): IActionBlueprint { + return { + tag: "list_directory_files", + class: ListDirectoryFilesAction, + description: "Lists files in one or more directories", + usageExplanation: + "Use this action to list all files within one or more directories. Optionally, enable recursive listing.", + parameters: [ + { + name: "path", + required: true, + description: + "Path or paths to the directories (can use multiple tags)", + mayContainNestedContent: false, + }, + { + name: "recursive", + required: false, + description: "Enable recursive listing", + mayContainNestedContent: false, + }, + ], + }; + } + + constructor( + protected actionTagsExtractor: ActionTagsExtractor, + private directoryScanner: DirectoryScanner, + private debugLogger: DebugLogger, + ) { + super(actionTagsExtractor); + } + + protected validateParams(params: IListDirectoryFilesParams): string | null { + if (!params.path) { + return "Must include at least one tag"; + } + + const paths = Array.isArray(params.path) ? params.path : [params.path]; + + for (const dirPath of paths) { + try { + const absolutePath = path.resolve(dirPath); + const stats = fs.statSync(absolutePath); + + if (!stats.isDirectory()) { + return `Path '${dirPath}' exists but is not a directory`; + } + } catch (error) { + return `Invalid or inaccessible path: ${dirPath}`; + } + } + + return null; + } + + protected async executeInternal( + params: IListDirectoryFilesParams, + ): Promise { + const paths = Array.isArray(params.path) ? params.path : [params.path]; + const allResults: string[] = []; + + for (const dirPath of paths) { + const result = await this.directoryScanner.scan(dirPath, { + maxDepth: params.recursive ? undefined : 1, + }); + + if (!result.success || !result.data) { + return this.createErrorResult( + `Failed to list directory contents for path: ${dirPath}. ${result.error || ""}`, + ); + } + + let content: string; + try { + content = + typeof result.data === "string" + ? result.data + : JSON.stringify(result.data, null, 2); + } catch (error) { + return this.createErrorResult( + `Failed to process directory contents for path: ${dirPath}. ${error.message}`, + ); + } + + allResults.push(content); + } + + this.logSuccess("Directory listing completed successfully.\n"); + return this.createSuccessResult(allResults.join("\n")); + } +} diff --git a/src/services/LLM/actions/__tests__/ListDirectoryFilesAction.test.ts b/src/services/LLM/actions/__tests__/ListDirectoryFilesAction.test.ts new file mode 100644 index 00000000..83fc623b --- /dev/null +++ b/src/services/LLM/actions/__tests__/ListDirectoryFilesAction.test.ts @@ -0,0 +1,164 @@ +import { UnitTestMocker } from "@/jest/mocks/UnitTestMocker"; +import { ConfigService } from "@/services/ConfigService"; +import { DirectoryScanner } from "@/services/FileManagement/DirectoryScanner"; +import { DebugLogger } from "@/services/logging/DebugLogger"; +import fs from "fs"; +import path from "path"; +import { ActionTagsExtractor } from "../ActionTagsExtractor"; +import { ListDirectoryFilesAction } from "../ListDirectoryFilesAction"; + +describe("ListDirectoryFilesAction", () => { + let action: ListDirectoryFilesAction; + let mockDirectoryScanner: DirectoryScanner; + let mocker: UnitTestMocker; + let mockConfigService: ConfigService; + let mockActionTagsExtractor: ActionTagsExtractor; + let consoleErrorSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + + beforeEach(() => { + mocker = new UnitTestMocker(); + + // Mock console methods + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + // Mock ConfigService + mockConfigService = new ConfigService(); + mocker.mockPrototype(ConfigService, "getConfig", { + directoryScanner: { + defaultIgnore: [], + allFiles: true, + maxDepth: 4, + directoryFirst: true, + excludeDirectories: [], + }, + }); + + mockDirectoryScanner = new DirectoryScanner(mockConfigService); + + // Mock filesystem operations + mocker.mockModule(fs, "statSync", { isDirectory: () => true }); + mocker.mockModule(path, "resolve", (p: string) => p); + + // Mock DirectoryScanner scan method + mocker.mockPrototype(DirectoryScanner, "scan", { + success: true, + data: "file1.txt\nfile2.txt", + }); + + // Mock ActionTagsExtractor + mockActionTagsExtractor = new ActionTagsExtractor(); + mocker.mockPrototypeWith( + ActionTagsExtractor, + "extractTag", + (content: string, tag: string) => { + if (tag === "path") { + if (content.includes("./src1./src2")) { + return ["./src1", "./src2"]; + } + return "./src"; + } + if (tag === "recursive") { + return content.includes("true") + ? "true" + : null; + } + return null; + }, + ); + + action = new ListDirectoryFilesAction( + mockActionTagsExtractor, + mockDirectoryScanner, + new DebugLogger(), + ); + }); + + afterEach(() => { + mocker.clearAllMocks(); + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + it("should list directory contents successfully", async () => { + const result = await action.execute( + "./src", + ); + + expect(result.success).toBe(true); + expect(result.data).toBe("file1.txt\nfile2.txt"); + }); + + it("should handle recursive listing", async () => { + mocker.mockPrototype(DirectoryScanner, "scan", { + success: true, + data: "dir1/file1.txt\ndir2/file2.txt", + }); + + const result = await action.execute( + "./srctrue", + ); + + expect(result.success).toBe(true); + expect(result.data).toBe("dir1/file1.txt\ndir2/file2.txt"); + }); + + it("should handle multiple paths", async () => { + const scanSpy = mocker.mockPrototypeWith( + DirectoryScanner, + "scan", + async (path: string) => { + if (path === "./src1") { + return { success: true, data: "src1/file1.txt\nsrc1/file2.txt" }; + } + return { success: true, data: "src2/file3.txt\nsrc2/file4.txt" }; + }, + ); + + const result = await action.execute( + "./src1./src2", + ); + + expect(result.success).toBe(true); + expect(result.data).toContain("src1/file1.txt"); + expect(result.data).toContain("src1/file2.txt"); + expect(result.data).toContain("src2/file3.txt"); + expect(result.data).toContain("src2/file4.txt"); + expect(scanSpy).toHaveBeenCalledTimes(2); + expect(scanSpy).toHaveBeenCalledWith("./src1", expect.any(Object)); + expect(scanSpy).toHaveBeenCalledWith("./src2", expect.any(Object)); + }); + + it("should handle multiple paths with recursive option", async () => { + const scanSpy = mocker.mockPrototypeWith( + DirectoryScanner, + "scan", + async (path: string) => { + if (path === "./src1") { + return { success: true, data: "src1/deep/file1.txt\nsrc1/file2.txt" }; + } + return { success: true, data: "src2/deep/file3.txt\nsrc2/file4.txt" }; + }, + ); + + const result = await action.execute( + "./src1./src2true", + ); + + expect(result.success).toBe(true); + expect(result.data).toContain("src1/deep/file1.txt"); + expect(result.data).toContain("src1/file2.txt"); + expect(result.data).toContain("src2/deep/file3.txt"); + expect(result.data).toContain("src2/file4.txt"); + expect(scanSpy).toHaveBeenCalledTimes(2); + expect(scanSpy).toHaveBeenCalledWith( + "./src1", + expect.objectContaining({ maxDepth: undefined }), + ); + expect(scanSpy).toHaveBeenCalledWith( + "./src2", + expect.objectContaining({ maxDepth: undefined }), + ); + }); +}); diff --git a/src/services/LLM/actions/blueprints/index.ts b/src/services/LLM/actions/blueprints/index.ts index d840da88..57ab9db7 100644 --- a/src/services/LLM/actions/blueprints/index.ts +++ b/src/services/LLM/actions/blueprints/index.ts @@ -10,9 +10,10 @@ import { endTaskActionBlueprint } from "./endTaskActionBlueprint"; import { fetchUrlActionBlueprint } from "./fetchUrlActionBlueprint"; import { gitDiffActionBlueprint } from "./gitDiffActionBlueprint"; import { gitPRDiffActionBlueprint } from "./gitPRDiffActionBlueprint"; +import { listDirectoryFilesActionBlueprint } from "./listDirectoryFilesActionBlueprint"; import { moveFileActionBlueprint } from "./moveFileActionBlueprint"; -import { readFileActionBlueprint } from "./readFileActionBlueprint"; import { readDirectoryActionBlueprint } from "./readDirectoryActionBlueprint"; +import { readFileActionBlueprint } from "./readFileActionBlueprint"; import { relativePathLookupActionBlueprint } from "./relativePathLookupActionBlueprint"; import { searchFileActionBlueprint, @@ -37,6 +38,7 @@ export const actionsBlueprints = { [searchFileActionBlueprint.tag]: searchFileActionBlueprint, [searchStringActionBlueprint.tag]: searchStringActionBlueprint, [writeFileActionBlueprint.tag]: writeFileActionBlueprint, + [listDirectoryFilesActionBlueprint.tag]: listDirectoryFilesActionBlueprint, [readDirectoryActionBlueprint.tag]: readDirectoryActionBlueprint, } as const; diff --git a/src/services/LLM/actions/blueprints/listDirectoryFilesActionBlueprint.ts b/src/services/LLM/actions/blueprints/listDirectoryFilesActionBlueprint.ts new file mode 100644 index 00000000..e5e2875b --- /dev/null +++ b/src/services/LLM/actions/blueprints/listDirectoryFilesActionBlueprint.ts @@ -0,0 +1,30 @@ +import { IActionBlueprint } from "../core/IAction"; +import { ListDirectoryFilesAction } from "../ListDirectoryFilesAction"; +import { ActionPriority } from "../types/ActionPriority"; + +export const listDirectoryFilesActionBlueprint: IActionBlueprint = { + tag: "list_directory_files", + class: ListDirectoryFilesAction, + description: "Lists all file paths inside a directory", + priority: ActionPriority.HIGH, + canRunInParallel: true, + requiresProcessing: false, + usageExplanation: + "./src", + parameters: [ + { + name: "path", + required: true, + description: "The directory path to list files from", + validator: (value: unknown): value is string => + typeof value === "string" && value.length > 0, + }, + { + name: "recursive", + required: false, + description: "Whether to list files recursively (default: false)", + validator: (value: unknown): value is boolean => + typeof value === "boolean", + }, + ], +}; diff --git a/src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts b/src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts index 35aa5c8c..c26c88cc 100644 --- a/src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts +++ b/src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts @@ -39,7 +39,49 @@ Only one action per reply. Use tags properly: file.ts -... + + + + +Ok, I have enough context to move to the next phase. + + + strategy_phase + + + + +## Allowed Actions + + +REMEMBER: ONLY ONE ACTION PER REPLY!!! + + + + path/here + + + + + + + + + + path/here + path/here/2 + false + + + + + + + + + + + ... ... @@ -51,6 +93,7 @@ Only one action per reply. Use tags properly: + directory/path directory/path/2 diff --git a/src/services/LLM/phases/blueprints/executePhaseBlueprint.ts b/src/services/LLM/phases/blueprints/executePhaseBlueprint.ts index 5c69fe56..a33bccf0 100644 --- a/src/services/LLM/phases/blueprints/executePhaseBlueprint.ts +++ b/src/services/LLM/phases/blueprints/executePhaseBlueprint.ts @@ -49,6 +49,14 @@ IF previous action was NOT write_file: + + + path/here + path/here/2 + false + + + path/here @@ -95,6 +103,7 @@ IF previous action was NOT write_file: + directory/path directory/path/2 diff --git a/src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts b/src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts index 332d2786..cb9607d3 100644 --- a/src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts +++ b/src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts @@ -47,6 +47,17 @@ YOU CAN ONLY USE THIS ONE TIME! Suggest write_file then immediately end_phase. + + + + + path/here + path/here/2 + false + + + +REMEMBER: ONLY ONE ACTION PER REPLY!!!