diff --git a/AIClasses/Mistral/Mistral.ts b/AIClasses/Mistral/Mistral.ts index 6eb4eac..d658f73 100644 --- a/AIClasses/Mistral/Mistral.ts +++ b/AIClasses/Mistral/Mistral.ts @@ -20,6 +20,7 @@ import type { MistralStreamChunk, MistralToolDefinition, MistralMessage, Mistral import type { MistralFileService } from "./MistralFileService"; import { Copy, replaceCopy } from "Enums/Copy"; import { MistralAgent } from "./MistralAgent"; +import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload"; export class Mistral extends BaseAIClass { @@ -97,7 +98,7 @@ export class Mistral extends BaseAIClass { } const query = (toolCall.arguments as Record).query ?? ""; const result = await this.agent.search(query); - return new AIToolResponse(toolCall.name, { result }, toolCall.toolId); + return new AIToolResponse(toolCall.name, new AIToolResponsePayload({ result }), toolCall.toolId); } private async buildMessages(conversation: Conversation): Promise { diff --git a/AIClasses/Schemas/AIToolSchemas.ts b/AIClasses/Schemas/AIToolSchemas.ts index 5197ac1..5ae6e79 100644 --- a/AIClasses/Schemas/AIToolSchemas.ts +++ b/AIClasses/Schemas/AIToolSchemas.ts @@ -38,24 +38,30 @@ export const MoveVaultFilesArgsSchema = z.object({ user_message: z.string() }); -export const CreateVaultFolderSchema = z.object({ +export const CreateVaultFolderArgsSchema = z.object({ path: z.string(), user_message: z.string() }); -export const DeleteVaultFolderSchema = z.object({ +export const DeleteVaultFolderArgsSchema = z.object({ path: z.string(), user_message: z.string(), confirm_deletion: z.boolean() }); +export const MoveVaultFolderArgsSchema = z.object({ + source_path: z.string(), + destination_path: z.string(), + user_message: z.string() +}); + export const ListVaultFilesArgsSchema = z.object({ path: z.string(), recursive: z.boolean(), user_message: z.string() }); -export const GetWebViewerContentSchema = z.object({ +export const GetWebViewerContentArgsSchema = z.object({ url_hint: z.string().optional(), format: z.enum(["text", "screenshot"]), user_message: z.string() @@ -145,10 +151,11 @@ export type WriteVaultFileArgs = z.infer; export type PatchVaultFileArgs = z.infer; export type DeleteVaultFilesArgs = z.infer; export type MoveVaultFilesArgs = z.infer; -export type CreateVaultFolder = z.infer; -export type DeleteVaultFolder = z.infer; +export type CreateVaultFolderArgs = z.infer; +export type DeleteVaultFolderArgs = z.infer; +export type MoveVaultFolderArgs = z.infer; export type ListVaultFilesArgs = z.infer; -export type GetWebViewerContent = z.infer; +export type GetWebViewerContentArgs = 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 4795000..f714850 100644 --- a/AIClasses/ToolDefinitions/AIToolDefinitions.ts +++ b/AIClasses/ToolDefinitions/AIToolDefinitions.ts @@ -21,6 +21,7 @@ import { UpdateMemories } from "./Tools/UpdateMemories"; import { CreateVaultFolder } from "./Tools/CreateVaultFolder"; import { GetWebViewerContent } from "./Tools/GetWebViewerContent"; import { DeleteVaultFolder } from "./Tools/DeleteVaultFolder"; +import { MoveVaultFolder } from "./Tools/MoveVaultFolder"; export abstract class AIToolDefinitions { @@ -28,7 +29,7 @@ export abstract class AIToolDefinitions { // 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, GetWebViewerContent, - WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder]; + WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder]; public static agentDefinitions(destructive: boolean, planningMode: boolean, memories: boolean, updateMemories: boolean): IAIToolDefinition[] { this.isGated = false; @@ -59,7 +60,8 @@ export abstract class AIToolDefinitions { DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, - DeleteVaultFolder + DeleteVaultFolder, + MoveVaultFolder ]); } diff --git a/AIClasses/ToolDefinitions/Tools/MoveVaultFolder.ts b/AIClasses/ToolDefinitions/Tools/MoveVaultFolder.ts new file mode 100644 index 0000000..20b96f5 --- /dev/null +++ b/AIClasses/ToolDefinitions/Tools/MoveVaultFolder.ts @@ -0,0 +1,36 @@ +import { AITool } from "Enums/AITool"; +import type { IAIToolDefinition } from "../IAIToolDefinition"; + +export const MoveVaultFolder: IAIToolDefinition = { + name: AITool.MoveVaultFolder, + description: `Moves or renames a folder within the vault to a new location. +This operation moves the folder and all its contents, preserving the internal structure. + +Call this function: +- When reorganizing vault structure and moving a folder to a new parent directory +- When renaming a folder for better organization +- When consolidating related folders under a common parent + +Do NOT use this function: +- Before reading folder contents to confirm you're moving the correct folder (especially for large operations) +- When moving individual files +- When the destination folder already exists at that path`, + parameters: { + type: "object", + properties: { + source_path: { + type: "string", + description: "The current path of the folder to move. Must be exact and point to an existing folder within the vault. Example: 'projects/old-name'" + }, + destination_path: { + type: "string", + description: "The destination path for the folder (including the folder name). Supports renaming by providing a different folder name. Example: 'projects/new-name' or 'archive/projects/old-name'. Ensure the parent directory exists." + }, + user_message: { + type: "string", + description: "A short message to be displayed to the user explaining why this folder is being moved. Examples: 'Moving your projects folder to the archive' (archiving), 'Renaming folder to match new naming convention' (renaming), or 'Reorganising your vault structure' (reorganizing)." + } + }, + required: ["source_path", "destination_path", "user_message"] + } +} \ No newline at end of file diff --git a/Enums/AITool.ts b/Enums/AITool.ts index 5eef44e..8c3ea73 100644 --- a/Enums/AITool.ts +++ b/Enums/AITool.ts @@ -7,6 +7,7 @@ export enum AITool { MoveVaultFiles = "move_vault_files", CreateVaultFolder = "create_vault_folder", DeleteVaultFolder = "delete_vault_folder", + MoveVaultFolder = "move_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 12fff86..e3cba1b 100644 --- a/Services/AIServices/AIToolService.ts +++ b/Services/AIServices/AIToolService.ts @@ -29,9 +29,10 @@ import { PatchVaultFileArgsSchema, ReadMemoriesArgsSchema, UpdateMemoriesArgsSchema, - CreateVaultFolderSchema, - GetWebViewerContentSchema, - DeleteVaultFolderSchema + CreateVaultFolderArgsSchema, + GetWebViewerContentArgsSchema, + DeleteVaultFolderArgsSchema, + MoveVaultFolderArgsSchema } from "AIClasses/Schemas/AIToolSchemas"; export class AIToolService { @@ -133,7 +134,7 @@ export class AIToolService { } case AITool.CreateVaultFolder: { - const parseResult = CreateVaultFolderSchema.safeParse(toolCall.arguments); + const parseResult = CreateVaultFolderArgsSchema.safeParse(toolCall.arguments); if (!parseResult.success) { return new AIToolResponse( toolCall.name, @@ -145,7 +146,7 @@ export class AIToolService { } case AITool.DeleteVaultFolder: { - const parseResult = DeleteVaultFolderSchema.safeParse(toolCall.arguments); + const parseResult = DeleteVaultFolderArgsSchema.safeParse(toolCall.arguments); if (!parseResult.success) { return new AIToolResponse( toolCall.name, @@ -155,6 +156,18 @@ export class AIToolService { } return new AIToolResponse(toolCall.name, await this.deleteVaultFolder(parseResult.data.path, parseResult.data.confirm_deletion), toolCall.toolId); } + + case AITool.MoveVaultFolder: { + const parseResult = MoveVaultFolderArgsSchema.safeParse(toolCall.arguments); + if (!parseResult.success) { + return new AIToolResponse( + toolCall.name, + new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.MoveVaultFolder}: ${parseResult.error.message}` }), + toolCall.toolId + ); + } + return new AIToolResponse(toolCall.name, await this.moveVaultFolder(parseResult.data.source_path, parseResult.data.destination_path), toolCall.toolId); + } case AITool.ListVaultFiles: { const parseResult = ListVaultFilesArgsSchema.safeParse(toolCall.arguments); @@ -169,7 +182,7 @@ export class AIToolService { } case AITool.GetWebViewerContent: { - const parseResult = GetWebViewerContentSchema.safeParse(toolCall.arguments); + const parseResult = GetWebViewerContentArgsSchema.safeParse(toolCall.arguments); if (!parseResult.success) { return new AIToolResponse( toolCall.name, @@ -377,6 +390,14 @@ export class AIToolService { : new AIToolResponsePayload({ path: path, success: true }); } + private async moveVaultFolder(sourcePath: string, destinationPath: string): Promise { + const result = await this.fileSystemService.moveFile(sourcePath, destinationPath); + if (result instanceof Error) { + return new AIToolResponsePayload({ path: destinationPath, success: false, error: result.message }); + } + return new AIToolResponsePayload({ path: destinationPath, success: true }); + } + private async listVaultFiles(path: string, recursive: boolean): Promise { const files: TAbstractFile[] = await this.fileSystemService.listDirectoryContents(path, recursive); return new AIToolResponsePayload(files.map(file => ({ diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 0ad958e..f260f4a 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -239,15 +239,22 @@ export class VaultService { const file = this.getAbstractFileByPath(sourcePath, allowAccessToPluginRoot); if (file === null) { - return Exception.new(`File does not exist: ${sourcePath}`); + return Exception.new(`Move failed as source does not exist: ${sourcePath}`); } + const isFile = file instanceof TFile; + if (this.isExclusion(destinationPath, allowAccessToPluginRoot)) { return Exception.new(`Failed to rename "${sourcePath}" to "${destinationPath}", permission denied.`) } try { - await this.createDirectories(destinationPath, allowAccessToPluginRoot) + if (isFile) { + await this.createDirectories(destinationPath, allowAccessToPluginRoot); + } else { + const parentPath = destinationPath.substring(0, destinationPath.lastIndexOf("/")); + if (parentPath) await this.createDirectories(parentPath, allowAccessToPluginRoot); + } await this.fileManager.renameFile(file, destinationPath); } catch (error) { Exception.log(error); diff --git a/__tests__/Services/FileSystemService.test.ts b/__tests__/Services/FileSystemService.test.ts index 4702f42..b74d524 100644 --- a/__tests__/Services/FileSystemService.test.ts +++ b/__tests__/Services/FileSystemService.test.ts @@ -345,16 +345,17 @@ describe('FileSystemService', () => { expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, true, true); }); - it('should delete folder successfully (supports both files and folders)', async () => { + it('should delete folder successfully', async () => { const mockFolder = createMockFolder('folder'); mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFolder); + mockVaultService.isExclusion = vi.fn().mockReturnValue(false); mockVaultService.delete = vi.fn().mockResolvedValue(undefined); - const result = await fileSystemService.deleteFile('folder'); + const result = await fileSystemService.deleteFolder('folder'); expect(result).toBeUndefined(); - expect(mockVaultService.delete).toHaveBeenCalledWith(mockFolder, false, true); + expect(mockVaultService.delete).toHaveBeenCalledWith(mockFolder, false); }); }); diff --git a/__tests__/Services/OrchestrationAgent.test.ts b/__tests__/Services/OrchestrationAgent.test.ts index 853ab96..6916b0e 100644 --- a/__tests__/Services/OrchestrationAgent.test.ts +++ b/__tests__/Services/OrchestrationAgent.test.ts @@ -4,6 +4,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende import { Services } from '../../Services/Services'; import { AITool } from '../../Enums/AITool'; import { AIToolResponse } from '../../AIClasses/ToolDefinitions/AIToolResponse'; +import { AIToolResponsePayload } from 'AIClasses/ToolDefinitions/AIToolResponsePayload'; /** * UNIT TESTS - OrchestrationAgent @@ -36,7 +37,7 @@ describe('OrchestrationAgent - Unit Tests', () => { // Mock AIToolService mockAIToolService = { performAITool: vi.fn().mockResolvedValue( - new AIToolResponse(AITool.SearchVaultFiles, { results: [] }, 'test-tool-id') + new AIToolResponse(AITool.SearchVaultFiles, new AIToolResponsePayload({ results: [] }), 'test-tool-id') ) }; RegisterSingleton(Services.AIToolService, mockAIToolService); diff --git a/__tests__/Services/VaultService.test.ts b/__tests__/Services/VaultService.test.ts index 9820f27..2e6c7bd 100644 --- a/__tests__/Services/VaultService.test.ts +++ b/__tests__/Services/VaultService.test.ts @@ -665,7 +665,7 @@ describe('VaultService - Integration Tests', () => { const result = await vaultService.move('Vaultkeeper AI/test.md', 'dest.md', false); expect(result).toBeInstanceOf(Error); - expect((result as Error).message).toContain('File does not exist'); + expect((result as Error).message).toContain('Move failed as source does not exist'); expect(mockFileManager.renameFile).not.toHaveBeenCalled(); }); @@ -675,7 +675,7 @@ describe('VaultService - Integration Tests', () => { const result = await vaultService.move('nonexistent.md', 'dest.md'); expect(result).toBeInstanceOf(Error); - expect((result as Error).message).toContain('File does not exist'); + expect((result as Error).message).toContain('Move failed as source does not exist'); expect(mockFileManager.renameFile).not.toHaveBeenCalled(); });