From ae321f382a92614c0a26c2768f6d7e580c0ea952 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Mon, 6 Apr 2026 18:07:59 +0100 Subject: [PATCH] feat: add create_vault_folder tool for directory creation Add new AI tool to create vault folders with proper permission checks and error handling. Refactor createDirectories to be public and handle both file and directory paths. Update WriteVaultFile description formatting and fix method naming consistency. --- AIClasses/Schemas/AIToolSchemas.ts | 6 +++ .../ToolDefinitions/AIToolDefinitions.ts | 6 ++- .../Tools/CreateVaultFolder.ts | 31 ++++++++++++ .../ToolDefinitions/Tools/WriteVaultFile.ts | 4 +- Enums/AITool.ts | 1 + Services/AIServices/AIToolService.ts | 37 ++++++++++---- Services/FileSystemService.ts | 4 ++ Services/VaultService.ts | 34 ++++++------- __tests__/Services/VaultService.test.ts | 48 +++++++++++++++---- 9 files changed, 132 insertions(+), 39 deletions(-) create mode 100644 AIClasses/ToolDefinitions/Tools/CreateVaultFolder.ts diff --git a/AIClasses/Schemas/AIToolSchemas.ts b/AIClasses/Schemas/AIToolSchemas.ts index 0493980..fb60768 100644 --- a/AIClasses/Schemas/AIToolSchemas.ts +++ b/AIClasses/Schemas/AIToolSchemas.ts @@ -40,6 +40,11 @@ export const MoveVaultFilesArgsSchema = z.object({ user_message: z.string() }); +export const CreateVaultFolderSchema = z.object({ + path: z.string(), + user_message: z.string() +}); + export const ListVaultFilesArgsSchema = z.object({ path: z.string(), recursive: z.boolean(), @@ -131,6 +136,7 @@ export type PatchVaultFileArgs = z.infer; export type DeleteVaultFilesArgs = z.infer; export type MoveVaultFilesArgs = z.infer; export type ListVaultFilesArgs = z.infer; +export type CreateVaultFolder = z.infer; export type ExecuteWorkflowArgs = z.infer; export type AskUserQuestionPlanningArgs = z.infer; export type AskUserQuestionExecutionArgs = z.infer; diff --git a/AIClasses/ToolDefinitions/AIToolDefinitions.ts b/AIClasses/ToolDefinitions/AIToolDefinitions.ts index e375088..cd8e672 100644 --- a/AIClasses/ToolDefinitions/AIToolDefinitions.ts +++ b/AIClasses/ToolDefinitions/AIToolDefinitions.ts @@ -18,13 +18,14 @@ import { RevisePlan } from "./Tools/RevisePlan"; import { SkipStep } from "./Tools/SkipStep"; import { ReadMemories } from "./Tools/ReadMemories"; import { UpdateMemories } from "./Tools/UpdateMemories"; +import { CreateVaultFolder } from "./Tools/CreateVaultFolder"; export abstract class AIToolDefinitions { public static isGated: boolean = false; // Definitions list provides a list of function definitions that does not include any planning functions (used as reference in planning agent prompt) - private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles]; + private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder]; public static agentDefinitions(destructive: boolean, planningMode: boolean, memories: boolean, updateMemories: boolean): IAIToolDefinition[] { this.isGated = false; @@ -52,7 +53,8 @@ export abstract class AIToolDefinitions { WriteVaultFile, PatchVaultFile, DeleteVaultFiles, - MoveVaultFiles + MoveVaultFiles, + CreateVaultFolder ]); } diff --git a/AIClasses/ToolDefinitions/Tools/CreateVaultFolder.ts b/AIClasses/ToolDefinitions/Tools/CreateVaultFolder.ts new file mode 100644 index 0000000..44fc8ad --- /dev/null +++ b/AIClasses/ToolDefinitions/Tools/CreateVaultFolder.ts @@ -0,0 +1,31 @@ +import { AITool } from "Enums/AITool"; +import type { IAIToolDefinition } from "../IAIToolDefinition"; + +export const CreateVaultFolder: IAIToolDefinition = { + name: AITool.CreateVaultFolder, + description: `Creates a new folder (directory) in the vault, including any necessary parent folders. + +Call this function: +- When setting up a new folder structure or organizational hierarchy +- When a file needs to be written to a folder that does not yet exist +- When the user explicitly asks to create a folder or directory + +Do NOT use this function: +- When the target folder already exists +- When writing a file to an existing folder (use WriteVaultFile directly instead) +- When renaming or moving an existing folder`, + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "The full path to the folder to create within the vault. Intermediate parent folders will be created automatically if they do not exist. Example: 'projects/2024/notes'" + }, + user_message: { + type: "string", + description: "A short message to be displayed to the user explaining what folder you're creating and why. Example: 'Creating a new Projects folder to organise your notes'" + } + }, + required: ["path", "user_message"] + } +} \ No newline at end of file diff --git a/AIClasses/ToolDefinitions/Tools/WriteVaultFile.ts b/AIClasses/ToolDefinitions/Tools/WriteVaultFile.ts index 6aa6dce..d5b2849 100644 --- a/AIClasses/ToolDefinitions/Tools/WriteVaultFile.ts +++ b/AIClasses/ToolDefinitions/Tools/WriteVaultFile.ts @@ -18,7 +18,7 @@ Do NOT use this function: properties: { file_path: { type: "string", - description: "The full path to the file within the vault (e.g., 'folder/note.md')" + description: "The full path to the file within the vault. Example: 'folder/note.md'" }, content: { type: "string", @@ -26,7 +26,7 @@ Do NOT use this function: }, user_message: { type: "string", - description: "A short message to be displayed to the user explaining what you're writing and why (e.g., 'Creating your daily note for today')" + description: "A short message to be displayed to the user explaining what you're writing and why. Example: 'Creating your daily note for today'" } }, required: ["file_path", "content", "user_message"] diff --git a/Enums/AITool.ts b/Enums/AITool.ts index 2bbe797..bfba286 100644 --- a/Enums/AITool.ts +++ b/Enums/AITool.ts @@ -5,6 +5,7 @@ export enum AITool { PatchVaultFile = "patch_vault_file", DeleteVaultFiles = "delete_vault_files", MoveVaultFiles = "move_vault_files", + CreateVaultFolder = "create_vault_folder", ListVaultFiles = "list_vault_files", ReadMemories = "read_memories", UpdateMemories = "update_memories", diff --git a/Services/AIServices/AIToolService.ts b/Services/AIServices/AIToolService.ts index 2d2e35d..a66e8ec 100644 --- a/Services/AIServices/AIToolService.ts +++ b/Services/AIServices/AIToolService.ts @@ -20,7 +20,8 @@ import { ListVaultFilesArgsSchema, PatchVaultFileArgsSchema, ReadMemoriesArgsSchema, - UpdateMemoriesArgsSchema + UpdateMemoriesArgsSchema, + CreateVaultFolderSchema } from "AIClasses/Schemas/AIToolSchemas"; import type { SettingsService } from "Services/SettingsService"; @@ -119,6 +120,18 @@ export class AIToolService { } return new AIToolResponse(toolCall.name, await this.moveVaultFiles(parseResult.data.source_paths, parseResult.data.destination_paths), toolCall.toolId); } + + case AITool.CreateVaultFolder: { + const parseResult = CreateVaultFolderSchema.safeParse(toolCall.arguments); + if (!parseResult.success) { + return new AIToolResponse( + toolCall.name, + { error: `Invalid arguments for ${AITool.CreateVaultFolder}: ${parseResult.error.message}` }, + toolCall.toolId + ); + } + return new AIToolResponse(toolCall.name, await this.createVaultFolder(parseResult.data.path), toolCall.toolId); + } case AITool.ListVaultFiles: { const parseResult = ListVaultFilesArgsSchema.safeParse(toolCall.arguments); @@ -129,7 +142,7 @@ export class AIToolService { toolCall.toolId ); } - return new AIToolResponse(toolCall.name, await this.ListVaultFiles(parseResult.data.path, parseResult.data.recursive), toolCall.toolId); + return new AIToolResponse(toolCall.name, await this.listVaultFiles(parseResult.data.path, parseResult.data.recursive), toolCall.toolId); } case AITool.ReadMemories: { @@ -222,13 +235,13 @@ export class AIToolService { filePaths.map(async (filePath) => { const result = await this.fileSystemService.readFile(filePath); if (result instanceof Error) { - return { path: filePath, error: result.message } + return { path: filePath, error: result.message }; } return { type: pathExtname(filePath), path: filePath, contents: result - } + }; }) ); return { results }; @@ -258,7 +271,7 @@ export class AIToolService { const results = await Promise.all(filePaths.map(async filePath => { const result = await this.fileSystemService.deleteFile(filePath); if (result instanceof Error) { - return { path: filePath, success: false, error: result.message } + return { path: filePath, success: false, error: result.message }; } return { path: filePath, success: true }; })); @@ -275,7 +288,7 @@ export class AIToolService { const destinationPath = destinationPaths[index]; const result = await this.fileSystemService.moveFile(sourcePath, destinationPath); if (result instanceof Error) { - return { path: destinationPath, success: false, error: result.message } + return { path: destinationPath, success: false, error: result.message }; } return { path: destinationPath, success: true }; })); @@ -283,7 +296,15 @@ export class AIToolService { return { results }; } - private async ListVaultFiles(path: string, recursive: boolean): Promise { + private async createVaultFolder(path: string): Promise { + const result = await this.fileSystemService.createFolder(path); + if (result instanceof Error) { + return { path: path, success: false, error: result.message }; + } + return { path: path, success: true }; + } + + private async listVaultFiles(path: string, recursive: boolean): Promise { const files: TAbstractFile[] = await this.fileSystemService.listDirectoryContents(path, recursive); return files.map(file => ({ type: file instanceof TFile ? "file" : "directory", @@ -301,7 +322,7 @@ export class AIToolService { private async updateMemories(content: string): Promise { if (!this.settingsService.settings.allowUpdatingMemories) { - return { error: Copy.MemoriesUpdatingDisabledError } + return { error: Copy.MemoriesUpdatingDisabledError }; } if (!this.lastToolReadMemories) { diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index 5f978be..25d70c4 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -91,6 +91,10 @@ export class FileSystemService { return await this.vaultService.move(sourcePath, destinationPath, allowAccessToPluginRoot); } + public async createFolder(path: string, allowAccessToPluginRoot: boolean = false): Promise { + return await this.vaultService.createDirectories(path, allowAccessToPluginRoot); + } + public async listFilesInDirectory(dirPath: string, recursive: boolean = true, allowAccessToPluginRoot: boolean = false): Promise { return await this.vaultService.listFilesInDirectory(dirPath, recursive, allowAccessToPluginRoot); } diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 1efa629..18cd467 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -284,15 +284,6 @@ export class VaultService { } } - public async createFolder(path: string, allowAccessToPluginRoot: boolean = false): Promise { - path = this.sanitiserService.sanitize(path); - if (this.isExclusion(path, allowAccessToPluginRoot)) { - Exception.log(`Plugin attempted to create a folder that is in the exclusion list: ${path}`); - return Exception.new(`Failed to create folder, permission denied: ${path}`); - } - return await this.vault.createFolder(path); - } - public async listDirectoryContents(path: string, recursive: boolean = true, allowAccessToPluginRoot: boolean = false): Promise { path = this.sanitiserService.sanitize(path); @@ -468,8 +459,10 @@ export class VaultService { }); } - private async createDirectories(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { - const dirPath: string = filePath.substring(0, filePath.lastIndexOf("/")); + public async createDirectories(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + const dirPath: string = path.extname(filePath) + ? filePath.substring(0, filePath.lastIndexOf("/")) + : filePath; const dirs: string[] = dirPath.split("/"); @@ -478,13 +471,11 @@ export class VaultService { for (const dir of dirs) { if (dir) { currentPath = currentPath ? `${currentPath}/${dir}` : dir; - try { - if (!(await this.exists(currentPath, allowAccessToPluginRoot))) { - await this.createFolder(currentPath, allowAccessToPluginRoot); + if (!(await this.exists(currentPath, allowAccessToPluginRoot))) { + const result = await this.createDirectory(currentPath, allowAccessToPluginRoot); + if (result instanceof Error) { + failures.push(currentPath); } - } catch (error) { - failures.push(currentPath); - Exception.log(error); } } } @@ -493,6 +484,15 @@ export class VaultService { } } + private async createDirectory(path: string, allowAccessToPluginRoot: boolean = false): Promise { + path = this.sanitiserService.sanitize(path); + if (this.isExclusion(path, allowAccessToPluginRoot)) { + Exception.log(`Plugin attempted to create a folder that is in the exclusion list: ${path}`); + return Exception.new(`Failed to create folder, permission denied: ${path}`); + } + return await this.vault.createFolder(path); + } + private extractSnippets(pages: IPageText[], regex: RegExp): ISearchSnippet[] { const allSnippets: ISearchSnippet[] = []; diff --git a/__tests__/Services/VaultService.test.ts b/__tests__/Services/VaultService.test.ts index da6ae12..9820f27 100644 --- a/__tests__/Services/VaultService.test.ts +++ b/__tests__/Services/VaultService.test.ts @@ -705,22 +705,50 @@ describe('VaultService - Integration Tests', () => { }); }); - describe('createFolder', () => { - it('should create folder with sanitized path', async () => { - const mockFolder = createMockFolder('folder'); - mockVault.createFolder.mockResolvedValue(mockFolder); + describe('createDirectories', () => { + it('should create a single directory with no slashes', async () => { + mockVault.getAbstractFileByPath.mockReturnValue(null); + mockVault.createFolder.mockResolvedValue(createMockFolder('Zap')); - const result = await vaultService.createFolder('folder'); + await vaultService.createDirectories('Zap'); - expect(mockVault.createFolder).toHaveBeenCalledWith('folder'); - expect(result).toBe(mockFolder); + expect(mockVault.createFolder).toHaveBeenCalledWith('Zap'); }); - it('should return error when trying to create folder in excluded path', async () => { - const result = await vaultService.createFolder('Vaultkeeper AI/subfolder', false); + it('should create all intermediate directories for a directory path', async () => { + mockVault.getAbstractFileByPath.mockReturnValue(null); + mockVault.createFolder.mockResolvedValue(createMockFolder('')); + + await vaultService.createDirectories('Zap/Test'); + + expect(mockVault.createFolder).toHaveBeenCalledWith('Zap'); + expect(mockVault.createFolder).toHaveBeenCalledWith('Zap/Test'); + }); + + it('should create parent directories for a file path', async () => { + mockVault.getAbstractFileByPath.mockReturnValue(null); + mockVault.createFolder.mockResolvedValue(createMockFolder('')); + + await vaultService.createDirectories('Zap/Test/file.md'); + + expect(mockVault.createFolder).toHaveBeenCalledWith('Zap'); + expect(mockVault.createFolder).toHaveBeenCalledWith('Zap/Test'); + expect(mockVault.createFolder).not.toHaveBeenCalledWith('Zap/Test/file.md'); + }); + + it('should skip directories that already exist', async () => { + mockVault.adapter.exists.mockResolvedValue(true); + + await vaultService.createDirectories('Zap'); + + expect(mockVault.createFolder).not.toHaveBeenCalled(); + }); + + it('should return error when trying to create directory in excluded path', async () => { + const result = await vaultService.createDirectories('Vaultkeeper AI/subfolder', false); expect(result).toBeInstanceOf(Error); - expect((result as Error).message).toContain('Failed to create folder, permission denied'); + expect((result as Error).message).toContain('Failed to create the following directories'); }); });