feat: add multi-patch support with whitespace-flexible matching

Enable PatchVaultFile to apply multiple patches in a single operation by converting oldContent and newContent from strings to arrays. Add whitespace-flexible regex matching as fallback when exact string matching fails, improving robustness for code with varying indentation. Update schemas, tool definitions, and all tests to reflect the new array-based API.
This commit is contained in:
Andrew Beal 2026-02-20 00:32:44 +00:00
parent f5ae83edaf
commit 984ed9eb82
9 changed files with 137 additions and 69 deletions

View file

@ -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()
});

View file

@ -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",

View file

@ -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;

View file

@ -202,7 +202,7 @@ export class AIToolService {
return { success: true };
}
private async patchVaultFile(filePath: string, oldContent: string, newContent: string): Promise<object> {
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<object> {
const result = await this.fileSystemService.patchFile(normalizePath(filePath), oldContent, newContent);
if (result instanceof Error) {
return { success: false, error: result.message };

View file

@ -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<TFile | Error> {
public async patchFile(filePath: string, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot);
let fileToPatch: TFile;

View file

@ -146,7 +146,7 @@ export class VaultService {
});
}
public async patch(file: TFile, oldContent: string, newContent: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
public async patch(file: TFile, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
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);

View file

@ -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,

View file

@ -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);

View file

@ -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);