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:
+175
-4
@@ -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
@@ -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` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]]');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user