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'); }); });