Add vault note management tools

Expand the tool executor with create_note, append_to_note, replace_note_section,
update_frontmatter, rename_note, move_note, delete_note, and insert_link tools.
Rename create_file to create_note for consistency and add helper methods for
common file operations. Update tests to cover the new tools and renamed
functionality.
This commit is contained in:
2026-05-20 18:19:09 +02:00
parent 8e338afeac
commit 95a6954b50
4 changed files with 906 additions and 20 deletions
+175 -4
View File
@@ -308,6 +308,28 @@ export class ChatView extends ItemView {
getTools(): OllamaTool[] {
return [
{
type: 'function',
function: {
name: 'create_note',
description:
'Creates a new note in the vault at the specified path with the given content',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the new note (e.g., "Projects/My Note.md")',
},
content: {
type: 'string',
description: 'The markdown content for the new note',
},
},
required: ['path', 'content'],
},
},
},
{
type: 'function',
function: {
@@ -320,10 +342,6 @@ export class ChatView extends ItemView {
type: 'string',
description: 'The path to the file to read',
},
content: {
type: 'string',
description: 'The content of the file to read',
},
},
required: ['path'],
},
@@ -350,6 +368,159 @@ export class ChatView extends ItemView {
},
},
},
{
type: 'function',
function: {
name: 'append_to_note',
description: 'Appends content to the end of an existing note',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
content: {
type: 'string',
description: 'The content to append',
},
},
required: ['path', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'replace_note_section',
description: 'Replaces the body of a section under the specified heading in a note',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
heading: {
type: 'string',
description: 'The heading text of the section to replace',
},
content: {
type: 'string',
description: 'The new content for the section (heading will be preserved)',
},
},
required: ['path', 'heading', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'update_frontmatter',
description:
'Updates YAML frontmatter fields in a note. Adds, updates, or removes fields.',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
fields: {
type: 'object',
description:
'An object of frontmatter key-value pairs to set. Use null to remove a field.',
},
},
required: ['path', 'fields'],
},
},
},
{
type: 'function',
function: {
name: 'rename_note',
description: 'Renames a note to a new path within the vault',
parameters: {
type: 'object',
properties: {
oldPath: {
type: 'string',
description: 'The current path to the note',
},
newPath: {
type: 'string',
description: 'The new path for the note',
},
},
required: ['oldPath', 'newPath'],
},
},
},
{
type: 'function',
function: {
name: 'move_note',
description: 'Moves a note into a different folder',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The current path to the note',
},
folder: {
type: 'string',
description: 'The target folder path (e.g., "Projects"). Use "" for vault root.',
},
},
required: ['path', 'folder'],
},
},
},
{
type: 'function',
function: {
name: 'delete_note',
description: 'Deletes a note from the vault',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note to delete',
},
},
required: ['path'],
},
},
},
{
type: 'function',
function: {
name: 'insert_link',
description: 'Inserts a wikilink to another note at the end of a source note',
parameters: {
type: 'object',
properties: {
sourcePath: {
type: 'string',
description: 'The path to the note that will contain the link',
},
targetPath: {
type: 'string',
description: 'The path to the note being linked to',
},
anchorText: {
type: 'string',
description: 'Optional display text for the link',
},
},
required: ['sourcePath', 'targetPath'],
},
},
},
];
}
+269 -9
View File
@@ -68,6 +68,24 @@ export class ToolExecutor {
return true;
}
private getFile(path: string): TFile {
const file = this.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
throw new Error(`File not found: ${path}`);
}
return file;
}
private async readFileContent(path: string): Promise<string> {
const file = this.getFile(path);
return await this.vault.cachedRead(file);
}
private async writeFileContent(path: string, content: string): Promise<void> {
const file = this.getFile(path);
await this.vault.modify(file, content);
}
async handleToolCall(toolCall: ToolCall): Promise<ToolResult> {
try {
const toolName = toolCall.function?.name;
@@ -94,11 +112,26 @@ export class ToolExecutor {
// Process the tool call based on its type
switch (toolName) {
case 'create_file':
return await this.handleCreateFile(parsedArgs);
case 'create_note':
return await this.handleCreateNote(parsedArgs);
case 'read_vault_file':
return await this.handleReadVaultFile(parsedArgs);
case 'search_vault_files':
return this.handleSearchVaultFiles(parsedArgs);
case 'append_to_note':
return await this.handleAppendToNote(parsedArgs);
case 'replace_note_section':
return await this.handleReplaceNoteSection(parsedArgs);
case 'update_frontmatter':
return await this.handleUpdateFrontmatter(parsedArgs);
case 'rename_note':
return await this.handleRenameNote(parsedArgs);
case 'move_note':
return await this.handleMoveNote(parsedArgs);
case 'delete_note':
return await this.handleDeleteNote(parsedArgs);
case 'insert_link':
return await this.handleInsertLink(parsedArgs);
default:
return { success: false, message: `Unknown tool: ${toolName}` };
}
@@ -108,7 +141,7 @@ export class ToolExecutor {
}
}
private async handleCreateFile(args: Record<string, unknown>): Promise<ToolResult> {
private async handleCreateNote(args: Record<string, unknown>): Promise<ToolResult> {
const path = args.path;
const content = args.content;
@@ -126,7 +159,7 @@ export class ToolExecutor {
try {
await this.vault.create(path, content);
return { success: true, message: 'File created successfully' };
return { success: true, message: 'Note created successfully' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(errorMessage);
@@ -155,12 +188,7 @@ export class ToolExecutor {
throw new Error('Invalid file path detected');
}
const file = this.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
throw new Error(`File not found: ${path}`);
}
const content = await this.vault.cachedRead(file);
const content = await this.readFileContent(path);
return {
success: true,
message: 'File read successfully',
@@ -190,4 +218,236 @@ export class ToolExecutor {
data: files,
};
}
private async handleAppendToNote(args: Record<string, unknown>): Promise<ToolResult> {
const path = args.path;
const content = args.content;
if (typeof path !== 'string') {
throw new Error('Path must be a string');
}
if (typeof content !== 'string') {
throw new Error('Content must be a string');
}
if (!this.isSafePath(path)) {
throw new Error('Invalid file path detected');
}
const currentContent = await this.readFileContent(path);
const separator = currentContent.endsWith('\n') ? '' : '\n';
const newContent = currentContent + separator + content;
await this.writeFileContent(path, newContent);
return { success: true, message: 'Content appended successfully' };
}
private async handleReplaceNoteSection(args: Record<string, unknown>): Promise<ToolResult> {
const path = args.path;
const heading = args.heading;
const content = args.content;
if (typeof path !== 'string') {
throw new Error('Path must be a string');
}
if (typeof heading !== 'string') {
throw new Error('Heading must be a string');
}
if (typeof content !== 'string') {
throw new Error('Content must be a string');
}
if (!this.isSafePath(path)) {
throw new Error('Invalid file path detected');
}
const fileContent = await this.readFileContent(path);
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const headingRegex = new RegExp(`^(#{1,6})\\s+${escapedHeading}\\s*$`, 'm');
const match = fileContent.match(headingRegex);
if (!match) {
throw new Error(`Heading "${heading}" not found in ${path}`);
}
const headingLevel = match[1].length;
const headingIndex = match.index!;
const afterHeading = headingIndex + match[0].length;
// Find next heading at same or higher level (fewer #)
const nextHeadingRegex = new RegExp(`^(#{1,${headingLevel}})\\s`, 'm');
const nextMatch = nextHeadingRegex.exec(fileContent.slice(afterHeading));
const sectionStart = headingIndex;
const sectionEnd = nextMatch ? afterHeading + nextMatch.index! : fileContent.length;
const newFileContent =
fileContent.slice(0, sectionStart) +
match[0] +
'\n' +
content +
'\n' +
fileContent.slice(sectionEnd);
await this.writeFileContent(path, newFileContent);
return { success: true, message: `Section "${heading}" replaced successfully` };
}
private parseFrontmatter(content: string): {
exists: boolean;
raw: string;
fields: Record<string, string>;
} {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
const match = content.match(frontmatterRegex);
if (!match) {
return { exists: false, raw: '', fields: {} };
}
const raw = match[1];
const fields: Record<string, string> = {};
for (const line of raw.split('\n')) {
const idx = line.indexOf(':');
if (idx > 0) {
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key) {
fields[key] = value;
}
}
}
return { exists: true, raw, fields };
}
private serializeFrontmatter(fields: Record<string, string>): string {
const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`);
return `---\n${lines.join('\n')}\n---\n`;
}
private async handleUpdateFrontmatter(args: Record<string, unknown>): Promise<ToolResult> {
const path = args.path;
const fields = args.fields;
if (typeof path !== 'string') {
throw new Error('Path must be a string');
}
if (!this.isSafePath(path)) {
throw new Error('Invalid file path detected');
}
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
throw new Error('Fields must be an object');
}
const content = await this.readFileContent(path);
const parsed = this.parseFrontmatter(content);
const newFields = { ...parsed.fields };
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
if (value === null || value === undefined) {
delete newFields[key];
} else if (typeof value === 'string') {
newFields[key] = value;
} else if (Array.isArray(value)) {
newFields[key] = value.join(', ');
} else {
newFields[key] = String(value);
}
}
const newFrontmatter = this.serializeFrontmatter(newFields);
const body = parsed.exists ? content.replace(/^---\n[\s\S]*?\n---\n/, '') : content;
const newContent = newFrontmatter + body;
await this.writeFileContent(path, newContent);
return { success: true, message: 'Frontmatter updated successfully' };
}
private async handleRenameNote(args: Record<string, unknown>): Promise<ToolResult> {
const oldPath = args.oldPath;
const newPath = args.newPath;
if (typeof oldPath !== 'string') {
throw new Error('oldPath must be a string');
}
if (typeof newPath !== 'string') {
throw new Error('newPath must be a string');
}
if (!this.isSafePath(oldPath) || !this.isSafePath(newPath)) {
throw new Error('Invalid file path detected');
}
const file = this.getFile(oldPath);
await this.vault.rename(file, newPath);
return { success: true, message: `Note renamed from ${oldPath} to ${newPath}` };
}
private async handleMoveNote(args: Record<string, unknown>): Promise<ToolResult> {
const path = args.path;
const folder = args.folder;
if (typeof path !== 'string') {
throw new Error('Path must be a string');
}
if (typeof folder !== 'string') {
throw new Error('Folder must be a string');
}
if (!this.isSafePath(path)) {
throw new Error('Invalid file path detected');
}
// Folder validation is more lenient (can be empty for root)
const normalizedFolder = folder.replace(/\/$/, '').trim();
if (normalizedFolder && !this.isSafePath(normalizedFolder)) {
throw new Error('Invalid folder path detected');
}
const file = this.getFile(path);
const fileName = file.name;
const newPath = normalizedFolder ? `${normalizedFolder}/${fileName}` : fileName;
await this.vault.rename(file, newPath);
return { success: true, message: `Note moved to ${newPath}` };
}
private async handleDeleteNote(args: Record<string, unknown>): Promise<ToolResult> {
const path = args.path;
if (typeof path !== 'string') {
throw new Error('Path must be a string');
}
if (!this.isSafePath(path)) {
throw new Error('Invalid file path detected');
}
const file = this.getFile(path);
await this.vault.delete(file);
return { success: true, message: `Note ${path} deleted successfully` };
}
private async handleInsertLink(args: Record<string, unknown>): Promise<ToolResult> {
const sourcePath = args.sourcePath;
const targetPath = args.targetPath;
const anchorText = args.anchorText;
if (typeof sourcePath !== 'string') {
throw new Error('sourcePath must be a string');
}
if (typeof targetPath !== 'string') {
throw new Error('targetPath must be a string');
}
if (!this.isSafePath(sourcePath) || !this.isSafePath(targetPath)) {
throw new Error('Invalid file path detected');
}
const currentContent = await this.readFileContent(sourcePath);
const linkText =
typeof anchorText === 'string' && anchorText.trim()
? `[[${targetPath}|${anchorText}]]`
: `[[${targetPath}]]`;
const separator = currentContent.endsWith('\n') ? '' : '\n';
const newContent = currentContent + separator + linkText + '\n';
await this.writeFileContent(sourcePath, newContent);
return { success: true, message: `Link to ${targetPath} inserted successfully` };
}
}
+1 -1
View File
@@ -453,7 +453,7 @@ describe('ChatView', () => {
const toolExecutor = view['toolExecutor'];
jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async (call) => {
if (call.function.name === 'create_file') {
return { success: true, message: 'File created successfully' };
return { success: true, message: 'Note created successfully' };
} else {
throw new Error('Tool not found');
}
+461 -6
View File
@@ -9,6 +9,9 @@ interface MockVault {
getAbstractFileByPath: (path: string) => any;
cachedRead: (file: any) => Promise<string>;
getMarkdownFiles: () => any[];
modify: (file: any, content: string) => Promise<void>;
rename: (file: any, newPath: string) => Promise<void>;
delete: (file: any) => Promise<void>;
}
interface MockApp {
// Mock app properties if needed
@@ -46,6 +49,9 @@ describe('ToolExecutor', () => {
getAbstractFileByPath: jest.fn(),
cachedRead: jest.fn().mockResolvedValue(''),
getMarkdownFiles: jest.fn().mockReturnValue([]),
modify: jest.fn().mockResolvedValue(undefined),
rename: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
mockApp = {} as MockApp;
executor = new ToolExecutor(mockVault as unknown as any, mockApp as unknown as any);
@@ -67,7 +73,7 @@ describe('ToolExecutor', () => {
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'File created successfully' });
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content');
});
@@ -84,7 +90,7 @@ describe('ToolExecutor', () => {
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'File created successfully' });
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('obj-args-file.md', 'Object args content');
});
@@ -101,7 +107,7 @@ describe('ToolExecutor', () => {
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'File created successfully' });
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith(
'subdirectory/test-file.md',
'Subdir content'
@@ -121,7 +127,7 @@ describe('ToolExecutor', () => {
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'File created successfully' });
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('test//file.md', 'Test content');
});
@@ -138,7 +144,7 @@ describe('ToolExecutor', () => {
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'File created successfully' });
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('empty-file.md', '');
});
@@ -155,7 +161,7 @@ describe('ToolExecutor', () => {
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'File created successfully' });
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('project..notes.md', 'Test content');
});
@@ -779,5 +785,454 @@ describe('ToolExecutor', () => {
expect(result).toEqual({ success: false, message: 'Unknown tool: unknown_tool' });
});
});
describe('create_note tool', () => {
it('should create a note successfully', async () => {
const call: ToolCall = {
id: 'call_cn1',
type: 'function',
function: {
name: 'create_note',
arguments: JSON.stringify({
path: 'New Note.md',
content: '# Hello\nWorld',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('New Note.md', '# Hello\nWorld');
});
});
describe('append_to_note tool', () => {
it('should append content to an existing note', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Existing content');
const call: ToolCall = {
id: 'call_an1',
type: 'function',
function: {
name: 'append_to_note',
arguments: JSON.stringify({
path: 'note.md',
content: 'Appended text',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.message).toBe('Content appended successfully');
expect(mockVault.modify).toHaveBeenCalled();
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toBe('Existing content\nAppended text');
});
it('should append without extra newline if content already ends with newline', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Existing content\n');
const call: ToolCall = {
id: 'call_an2',
type: 'function',
function: {
name: 'append_to_note',
arguments: JSON.stringify({
path: 'note.md',
content: 'Appended text',
}),
},
};
await executor.handleToolCall(call);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toBe('Existing content\nAppended text');
});
});
describe('replace_note_section tool', () => {
it('should replace a section under a heading', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const originalContent = `# Title\n\n## Section A\nOld content\n\n## Section B\nOther content`;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
const call: ToolCall = {
id: 'call_rs1',
type: 'function',
function: {
name: 'replace_note_section',
arguments: JSON.stringify({
path: 'note.md',
heading: 'Section A',
content: 'New content',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.message).toBe('Section "Section A" replaced successfully');
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('New content');
expect(modifiedContent).not.toContain('Old content');
expect(modifiedContent).toContain('## Section B');
});
it('should throw error when heading not found', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('# Title\nBody');
const call: ToolCall = {
id: 'call_rs2',
type: 'function',
function: {
name: 'replace_note_section',
arguments: JSON.stringify({
path: 'note.md',
heading: 'Missing Section',
content: 'New content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow(
'Heading "Missing Section" not found'
);
});
});
describe('update_frontmatter tool', () => {
it('should update existing frontmatter fields', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const originalContent = '---\ntitle: Old Title\ntags: idea\n---\nBody';
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
const call: ToolCall = {
id: 'call_uf1',
type: 'function',
function: {
name: 'update_frontmatter',
arguments: JSON.stringify({
path: 'note.md',
fields: { title: 'New Title', status: 'done' },
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('title: New Title');
expect(modifiedContent).toContain('tags: idea');
expect(modifiedContent).toContain('status: done');
});
it('should create frontmatter if none exists', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Just body content');
const call: ToolCall = {
id: 'call_uf2',
type: 'function',
function: {
name: 'update_frontmatter',
arguments: JSON.stringify({
path: 'note.md',
fields: { title: 'New Note' },
}),
},
};
await executor.handleToolCall(call);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('---');
expect(modifiedContent).toContain('title: New Note');
expect(modifiedContent).toContain('Just body content');
});
it('should remove a field when set to null', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const originalContent = '---\ntitle: Note\ndraft: true\n---\nBody';
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
const call: ToolCall = {
id: 'call_uf3',
type: 'function',
function: {
name: 'update_frontmatter',
arguments: JSON.stringify({
path: 'note.md',
fields: { draft: null },
}),
},
};
await executor.handleToolCall(call);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('title: Note');
expect(modifiedContent).not.toContain('draft: true');
});
});
describe('rename_note tool', () => {
it('should rename a note', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const file = new MockTFile('old.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
const call: ToolCall = {
id: 'call_rn1',
type: 'function',
function: {
name: 'rename_note',
arguments: JSON.stringify({
oldPath: 'old.md',
newPath: 'new.md',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(mockVault.rename).toHaveBeenCalledWith(file, 'new.md');
});
});
describe('move_note tool', () => {
it('should move a note into a folder', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const file = new MockTFile('Projects/old.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
const call: ToolCall = {
id: 'call_mn1',
type: 'function',
function: {
name: 'move_note',
arguments: JSON.stringify({
path: 'Projects/old.md',
folder: 'Archive',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(mockVault.rename).toHaveBeenCalledWith(file, 'Archive/old.md');
});
});
describe('delete_note tool', () => {
it('should delete a note', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const file = new MockTFile('note.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
const call: ToolCall = {
id: 'call_dn1',
type: 'function',
function: {
name: 'delete_note',
arguments: JSON.stringify({
path: 'note.md',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(mockVault.delete).toHaveBeenCalledWith(file);
});
});
describe('insert_link tool', () => {
it('should insert a wikilink without anchor text', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('source.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Source content');
const call: ToolCall = {
id: 'call_il1',
type: 'function',
function: {
name: 'insert_link',
arguments: JSON.stringify({
sourcePath: 'source.md',
targetPath: 'target.md',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('[[target.md]]');
});
it('should insert a wikilink with anchor text', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('source.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Source content');
const call: ToolCall = {
id: 'call_il2',
type: 'function',
function: {
name: 'insert_link',
arguments: JSON.stringify({
sourcePath: 'source.md',
targetPath: 'target.md',
anchorText: 'My Target',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('[[target.md|My Target]]');
});
});
});
});