diff --git a/AIClasses/Schemas/AIToolSchemas.ts b/AIClasses/Schemas/AIToolSchemas.ts index 44c5779..80603dc 100644 --- a/AIClasses/Schemas/AIToolSchemas.ts +++ b/AIClasses/Schemas/AIToolSchemas.ts @@ -23,8 +23,8 @@ export const WriteVaultFileArgsSchema = z.object({ export const PatchVaultFileArgsSchema = z.object({ file_path: z.string(), - oldContent: z.string(), - newContent: z.string(), + oldContent: z.array(z.string()), + newContent: z.array(z.string()), user_message: z.string() }); diff --git a/AIClasses/ToolDefinitions/Tools/PatchVaultFile.ts b/AIClasses/ToolDefinitions/Tools/PatchVaultFile.ts index 9bdce0a..c303cd7 100644 --- a/AIClasses/ToolDefinitions/Tools/PatchVaultFile.ts +++ b/AIClasses/ToolDefinitions/Tools/PatchVaultFile.ts @@ -5,7 +5,7 @@ export const PatchVaultFile: IAIToolDefinition = { name: AITool.PatchVaultFile, description: `Apply targeted changes to an existing file in the vault by finding and replacing specific content. -This tool modifies specific sections of a file by matching exact content and replacing it with new content. It works by performing a direct string match and replace operation. +This tool modifies specific sections of a file by matching exact content and replacing it with new content. It works by performing direct string match and replace operations. Multiple patches can be applied in a single call by providing corresponding arrays of old and new content. **CRITICAL:** The content to match must be EXACTLY as it appears in the file - including all whitespace, indentation, blank lines, and line breaks. @@ -14,6 +14,7 @@ Call this function: - When making edits in the middle of a file where you have clear surrounding context - When making simple line additions, deletions, or replacements with minimal changes - When you know the exact content that needs to be changed +- When making multiple independent edits to the same file (use multiple entries in the arrays) Do NOT use this function: - When rewriting most or all of a file's content @@ -27,26 +28,33 @@ Do NOT use this function: description: "The full path to the file within the vault (e.g., 'folder/note.md')" }, oldContent: { - type: "string", - description: `The exact content to find and replace in the file. + type: "array", + items: { + type: "string" + }, + description: `An array of exact content strings to find in the file. Each entry corresponds to a matching entry in newContent at the same index. CRITICAL MATCHING REQUIREMENTS: -- Must match the file content EXACTLY character-for-character +- Each entry must match the file content EXACTLY character-for-character - Include all whitespace, indentation, and line breaks exactly as they appear -- Include enough context to make the match unique within the file +- Include enough context to make each match unique within the file - Typically include 2-3 lines before and after the change for context - Match complete lines/statements to avoid breaking code structure - Preserve all blank lines that exist in the original WARNING: The replacement will fail if: -- The content doesn't exist in the file +- Any content entry doesn't exist in the file - Whitespace/indentation doesn't match exactly -- The match is ambiguous (appears multiple times in the file) -- Line breaks are missing or incorrect` +- Any match is ambiguous (appears multiple times in the file) +- Line breaks are missing or incorrect +- The number of oldContent entries doesn't match the number of newContent entries` }, newContent: { - type: "string", - description: `The new content that will replace the old content. Ensure proper indentation and formatting matches the surrounding code.` + type: "array", + items: { + type: "string" + }, + description: `An array of new content strings that will replace the corresponding old content at the same index. Each entry must have a matching entry in oldContent. Ensure proper indentation and formatting matches the surrounding code.` }, user_message: { type: "string", diff --git a/Helpers/StringTools.ts b/Helpers/StringTools.ts index 613010d..7f821b3 100644 --- a/Helpers/StringTools.ts +++ b/Helpers/StringTools.ts @@ -37,6 +37,12 @@ export abstract class StringTools { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } + // Builds a regex from a string that matches flexibly on whitespace but strictly on all other characters. + public static toWhitespaceFlexibleRegex(input: string): RegExp { + const pattern = this.escapeRegex(input).replace(/(\\\s|\s)+/g, "\\s+"); + return new RegExp(pattern); + } + public static asRegex(input: string, requiredFlags: string[]): RegExp | null { let regex: RegExp; diff --git a/Services/AIServices/AIToolService.ts b/Services/AIServices/AIToolService.ts index 3af5452..a50fd00 100644 --- a/Services/AIServices/AIToolService.ts +++ b/Services/AIServices/AIToolService.ts @@ -202,7 +202,7 @@ export class AIToolService { return { success: true }; } - private async patchVaultFile(filePath: string, oldContent: string, newContent: string): Promise { + private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise { const result = await this.fileSystemService.patchFile(normalizePath(filePath), oldContent, newContent); if (result instanceof Error) { return { success: false, error: result.message }; diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index af575a7..5f978be 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -59,7 +59,7 @@ export class FileSystemService { return await this.vaultService.modifyBinary(file, data, allowAccessToPluginRoot); } - public async patchFile(filePath: string, oldContent: string, newContent: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { + public async patchFile(filePath: string, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); let fileToPatch: TFile; diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 5f73d01..bc8d473 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -146,7 +146,7 @@ export class VaultService { }); } - public async patch(file: TFile, oldContent: string, newContent: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { + public async patch(file: TFile, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const filePath = this.sanitiserService.sanitize(file.path); if (this.isExclusion(file.path, allowAccessToPluginRoot)) { Exception.log(`Plugin attempted to patch a file that is in the exclusion list: ${filePath}`); @@ -154,7 +154,11 @@ export class VaultService { } if (isFileType(pathExtname(filePath), FileType.PDF)) { - return Exception.new("Creating PDF files is not supported"); + return Exception.new("Patching PDF files is not supported"); + } + + if (oldContent.length !== newContent.length) { + return Exception.new(`Mismatched patch arrays: ${oldContent.length} old content entries but ${newContent.length} new content entries. Each old content entry must have a corresponding new content entry.`); } const currentContent = await this.read(file, allowAccessToPluginRoot); @@ -163,11 +167,20 @@ export class VaultService { return currentContent; } - if (!currentContent.includes(oldContent)) { - return Exception.new(`Content to replace was not found in the file. The old content must match exactly.`); + for (let content of oldContent) { + if (!currentContent.includes(content) && !StringTools.toWhitespaceFlexibleRegex(content).test(currentContent)) { + return Exception.new(`Content to replace was not found in the file, the old content must match exactly. No changes have been made. Unmatched content: "${content}"`); + } } - const updatedContent = currentContent.replace(oldContent, newContent); + let updatedContent = currentContent; + for (let i = 0; i < oldContent.length; i++) { + if (updatedContent.includes(oldContent[i])) { + updatedContent = updatedContent.replace(oldContent[i], newContent[i]); + } else { + updatedContent = updatedContent.replace(StringTools.toWhitespaceFlexibleRegex(oldContent[i]), newContent[i]); + } + } return this.proposeChange(file.name, file.name, currentContent, updatedContent, requiresConfirmation, async () => { await this.vault.process(file, () => updatedContent); diff --git a/__tests__/Services/AIToolService.test.ts b/__tests__/Services/AIToolService.test.ts index 067d3f0..2af2992 100644 --- a/__tests__/Services/AIToolService.test.ts +++ b/__tests__/Services/AIToolService.test.ts @@ -348,8 +348,8 @@ describe('AIToolService - Integration Tests', () => { it('should apply patch successfully', async () => { mockFileSystemService.patchFile.mockResolvedValue(createMockFile('notes/test.md', 'test')); - const oldContent = 'old content'; - const newContent = 'new content'; + const oldContent = ['old content']; + const newContent = ['new content']; const result = await service.performAITool({ name: AITool.PatchVaultFile, @@ -370,8 +370,8 @@ describe('AIToolService - Integration Tests', () => { const error = new Error('Content to replace was not found in the file'); mockFileSystemService.patchFile.mockResolvedValue(error); - const oldContent = 'old content'; - const newContent = 'new content'; + const oldContent = ['old content']; + const newContent = ['new content']; const result = await service.performAITool({ name: AITool.PatchVaultFile, @@ -391,8 +391,8 @@ describe('AIToolService - Integration Tests', () => { it('should normalize file path', async () => { mockFileSystemService.patchFile.mockResolvedValue(createMockFile('folder/file.md', 'file')); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; await service.performAITool({ name: AITool.PatchVaultFile, @@ -417,8 +417,8 @@ describe('AIToolService - Integration Tests', () => { const error = new Error('File does not exist: missing.md'); mockFileSystemService.patchFile.mockResolvedValue(error); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; const result = await service.performAITool({ name: AITool.PatchVaultFile, @@ -438,8 +438,8 @@ describe('AIToolService - Integration Tests', () => { it('should handle complex multi-line replacement', async () => { mockFileSystemService.patchFile.mockResolvedValue(createMockFile('complex.md', 'complex')); - const oldContent = '# Title\nOld line 1\nContext line'; - const newContent = '# Title\nNew line 1\nContext line'; + const oldContent = ['# Title\nOld line 1\nContext line']; + const newContent = ['# Title\nNew line 1\nContext line']; const result = await service.performAITool({ name: AITool.PatchVaultFile, @@ -459,8 +459,8 @@ describe('AIToolService - Integration Tests', () => { it('should handle adding new content', async () => { mockFileSystemService.patchFile.mockResolvedValue(createMockFile('additions.md', 'additions')); - const oldContent = '# Title\nExisting content'; - const newContent = '# Title\nExisting content\nNew line 1\nNew line 2'; + const oldContent = ['# Title\nExisting content']; + const newContent = ['# Title\nExisting content\nNew line 1\nNew line 2']; const result = await service.performAITool({ name: AITool.PatchVaultFile, @@ -479,8 +479,8 @@ describe('AIToolService - Integration Tests', () => { it('should handle removing content', async () => { mockFileSystemService.patchFile.mockResolvedValue(createMockFile('deletions.md', 'deletions')); - const oldContent = '# Title\nLine to remove 1\nLine to remove 2\nRemaining content'; - const newContent = '# Title\nRemaining content'; + const oldContent = ['# Title\nLine to remove 1\nLine to remove 2\nRemaining content']; + const newContent = ['# Title\nRemaining content']; const result = await service.performAITool({ name: AITool.PatchVaultFile, @@ -500,8 +500,8 @@ describe('AIToolService - Integration Tests', () => { const error = new Error('Permission denied'); mockFileSystemService.patchFile.mockResolvedValue(error); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; const result = await service.performAITool({ name: AITool.PatchVaultFile, @@ -884,8 +884,8 @@ describe('AIToolService - Integration Tests', () => { // Then patch it mockFileSystemService.patchFile.mockResolvedValue(createMockFile('document.md', 'document')); - const oldContent = '# Original Title'; - const newContent = '# Updated Title'; + const oldContent = ['# Original Title']; + const newContent = ['# Updated Title']; const patchResult = await service.performAITool({ name: AITool.PatchVaultFile, diff --git a/__tests__/Services/FileSystemService.test.ts b/__tests__/Services/FileSystemService.test.ts index 0d3cd54..4702f42 100644 --- a/__tests__/Services/FileSystemService.test.ts +++ b/__tests__/Services/FileSystemService.test.ts @@ -191,8 +191,8 @@ describe('FileSystemService', () => { describe('patchFile', () => { it('should patch existing file successfully', async () => { const mockFile = createMockFile('existing.md'); - const oldContent = 'old content'; - const newContent = 'new content'; + const oldContent = ['old content']; + const newContent = ['new content']; mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile); mockVaultService.patch = vi.fn().mockResolvedValue(mockFile); @@ -205,8 +205,8 @@ describe('FileSystemService', () => { it('should create file with empty content when file does not exist', async () => { const mockFile = createMockFile('new.md'); - const oldContent = ''; - const newContent = '# New File\nContent here'; + const oldContent = ['']; + const newContent = ['# New File\nContent here']; // Mock sequence: first call returns null (file doesn't exist), second call returns null (writeFile checks), then create succeeds mockVaultService.getAbstractFileByPath = vi.fn() @@ -225,8 +225,8 @@ describe('FileSystemService', () => { it('should return error when patch fails', async () => { const mockFile = createMockFile('existing.md'); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; const error = new Error('Content to replace was not found in the file'); mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile); @@ -239,8 +239,8 @@ describe('FileSystemService', () => { }); it('should return error when creating empty file fails', async () => { - const oldContent = ''; - const newContent = 'New line'; + const oldContent = ['']; + const newContent = ['New line']; const error = new Error('Create failed'); mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null); @@ -255,8 +255,8 @@ describe('FileSystemService', () => { it('should respect allowAccessToPluginRoot parameter', async () => { const mockFile = createMockFile('plugin/config.md'); - const oldContent = 'setting=old'; - const newContent = 'setting=new'; + const oldContent = ['setting=old']; + const newContent = ['setting=new']; mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile); mockVaultService.patch = vi.fn().mockResolvedValue(mockFile); @@ -269,8 +269,8 @@ describe('FileSystemService', () => { it('should handle complex multi-line replacement', async () => { const mockFile = createMockFile('document.md'); - const oldContent = '# Title\nOld intro\nContent'; - const newContent = '# Title\nNew intro\nContent'; + const oldContent = ['# Title\nOld intro\nContent']; + const newContent = ['# Title\nNew intro\nContent']; mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile); mockVaultService.patch = vi.fn().mockResolvedValue(mockFile); @@ -282,8 +282,8 @@ describe('FileSystemService', () => { }); it('should handle file creation when getAbstractFileByPath returns null after create', async () => { - const oldContent = ''; - const newContent = 'content'; + const oldContent = ['']; + const newContent = ['content']; mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null); mockVaultService.create = vi.fn().mockResolvedValue(createMockFile('new.md')); @@ -298,8 +298,8 @@ describe('FileSystemService', () => { it('should respect requiresConfirmation parameter', async () => { const mockFile = createMockFile('test.md'); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile); mockVaultService.patch = vi.fn().mockResolvedValue(mockFile); diff --git a/__tests__/Services/VaultService.test.ts b/__tests__/Services/VaultService.test.ts index 1b254cc..64678d6 100644 --- a/__tests__/Services/VaultService.test.ts +++ b/__tests__/Services/VaultService.test.ts @@ -416,8 +416,8 @@ describe('VaultService - Integration Tests', () => { describe('patch', () => { it('should apply patch successfully when file is not excluded', async () => { const mockFile = createMockFile('note.md'); - const oldContent = 'old content'; - const newContent = 'new content'; + const oldContent = ['old content']; + const newContent = ['new content']; const currentContent = '# Title\nold content\nmore lines'; mockVault.read.mockResolvedValue(currentContent); @@ -434,8 +434,8 @@ describe('VaultService - Integration Tests', () => { it('should return error when file is excluded', async () => { const mockFile = createMockFile('Vaultkeeper AI/test.md'); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; const result = await vaultService.patch(mockFile, oldContent, newContent, false); @@ -446,8 +446,8 @@ describe('VaultService - Integration Tests', () => { it('should return error when old content is not found in file', async () => { const mockFile = createMockFile('note.md'); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; const currentContent = '# Different content'; mockVault.read.mockResolvedValue(currentContent); @@ -461,8 +461,8 @@ describe('VaultService - Integration Tests', () => { it('should allow patching excluded files when allowAccessToPluginRoot is true', async () => { const mockFile = createMockFile('Vaultkeeper AI/config.md'); - const oldContent = 'setting=old'; - const newContent = 'setting=new'; + const oldContent = ['setting=old']; + const newContent = ['setting=new']; const currentContent = 'setting=old'; mockVault.read.mockResolvedValue(currentContent); @@ -477,8 +477,8 @@ describe('VaultService - Integration Tests', () => { it('should request diff confirmation when requiresConfirmation is true', async () => { const mockFile = createMockFile('note.md'); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; const currentContent = 'old'; const updatedContent = 'new'; @@ -498,8 +498,8 @@ describe('VaultService - Integration Tests', () => { it('should replace only the first occurrence of old content', async () => { const mockFile = createMockFile('document.md'); - const oldContent = 'duplicate text'; - const newContent = 'replaced text'; + const oldContent = ['duplicate text']; + const newContent = ['replaced text']; const currentContent = 'duplicate text\nSome content\nduplicate text'; const expectedContent = 'replaced text\nSome content\nduplicate text'; @@ -519,8 +519,8 @@ describe('VaultService - Integration Tests', () => { it('should call vault.process with function that returns updated content', async () => { const mockFile = createMockFile('note.md'); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; const updatedContent = 'new'; let processCallback: any; @@ -536,10 +536,51 @@ describe('VaultService - Integration Tests', () => { expect(processCallback()).toBe(updatedContent); }); + it('should fall back to whitespace-flexible matching when exact match fails', async () => { + const mockFile = createMockFile('note.md'); + const oldContent = [' if (x) {\n return true;\n }']; + const newContent = [' if (x) {\n return false;\n }']; + const currentContent = ' if (x) {\n return true;\n }'; + + mockVault.read.mockResolvedValue(currentContent); + mockDiffService.requestDiff.mockResolvedValue({ accepted: true }); + + let processCallback: any; + mockVault.process.mockImplementation((_file, fn) => { + processCallback = fn; + return Promise.resolve(); + }); + + const result = await vaultService.patch(mockFile, oldContent, newContent); + + expect(result).toBe(mockFile); + expect(processCallback()).toBe(' if (x) {\n return false;\n }'); + }); + + it('should prefer exact match over whitespace-flexible match', async () => { + const mockFile = createMockFile('note.md'); + const oldContent = ['hello world']; + const newContent = ['goodbye world']; + const currentContent = 'hello world'; + + mockVault.read.mockResolvedValue(currentContent); + mockDiffService.requestDiff.mockResolvedValue({ accepted: true }); + + let processCallback: any; + mockVault.process.mockImplementation((_file, fn) => { + processCallback = fn; + return Promise.resolve(); + }); + + await vaultService.patch(mockFile, oldContent, newContent); + + expect(processCallback()).toBe('goodbye world'); + }); + it('should skip confirmation when requiresConfirmation is false', async () => { const mockFile = createMockFile('note.md'); - const oldContent = 'old'; - const newContent = 'new'; + const oldContent = ['old']; + const newContent = ['new']; mockVault.read.mockResolvedValue('old'); mockVault.process.mockResolvedValue(undefined);