diff --git a/apps/server/src/services/llm/chat_service.ts b/apps/server/src/services/llm/chat_service.ts index 2268f2822..6066714e6 100644 --- a/apps/server/src/services/llm/chat_service.ts +++ b/apps/server/src/services/llm/chat_service.ts @@ -505,7 +505,7 @@ export class ChatService { async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise { log.info(`========== CHAT SERVICE FLOW CHECK ==========`); log.info(`Entered generateChatCompletion in ChatService`); - log.info(`Using pipeline for chat completion: ${this.getPipeline(options.pipeline).constructor.name}`); + log.info(`Using pipeline for chat completion: pipelineV2`); log.info(`Tool support enabled: ${options.enableTools !== false}`); try { diff --git a/apps/server/src/services/llm/tools/calendar_integration_tool.ts b/apps/server/src/services/llm/tools/calendar_integration_tool.ts deleted file mode 100644 index 2089694e9..000000000 --- a/apps/server/src/services/llm/tools/calendar_integration_tool.ts +++ /dev/null @@ -1,482 +0,0 @@ -/** - * Calendar Integration Tool - * - * This tool allows the LLM to find date-related notes or create date-based entries. - */ - -import type { Tool, ToolHandler } from './tool_interfaces.js'; -import log from '../../log.js'; -import becca from '../../../becca/becca.js'; -import notes from '../../notes.js'; -import attributes from '../../attributes.js'; -import dateNotes from '../../date_notes.js'; - -/** - * Definition of the calendar integration tool - */ -export const calendarIntegrationToolDefinition: Tool = { - type: 'function', - function: { - name: 'calendar_integration', - description: 'Manage date-based notes: find notes by date/range, create dated entries, or get daily notes. Supports YYYY-MM-DD format.', - parameters: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Action to perform', - enum: ['find_date_notes', 'create_date_note', 'find_notes_with_date_range', 'get_daily_note'] - }, - date: { - type: 'string', - description: 'Date in ISO format (YYYY-MM-DD) for the note' - }, - dateStart: { - type: 'string', - description: 'Start date in ISO format (YYYY-MM-DD) for date range queries' - }, - dateEnd: { - type: 'string', - description: 'End date in ISO format (YYYY-MM-DD) for date range queries' - }, - title: { - type: 'string', - description: 'Title for creating a new date-related note' - }, - content: { - type: 'string', - description: 'Content for creating a new date-related note' - }, - parentNoteId: { - type: 'string', - description: 'Optional parent note ID for the new date note. If not specified, will use default calendar container.' - } - }, - required: ['action'] - } - } -}; - -/** - * Calendar integration tool implementation - */ -export class CalendarIntegrationTool implements ToolHandler { - public definition: Tool = calendarIntegrationToolDefinition; - - /** - * Execute the calendar integration tool - */ - public async execute(args: { - action: string, - date?: string, - dateStart?: string, - dateEnd?: string, - title?: string, - content?: string, - parentNoteId?: string - }): Promise { - try { - const { action, date, dateStart, dateEnd, title, content, parentNoteId } = args; - - log.info(`Executing calendar_integration tool - Action: ${action}, Date: ${date || 'not specified'}`); - - // Handle different actions - if (action === 'find_date_notes') { - return await this.findDateNotes(date); - } else if (action === 'create_date_note') { - return await this.createDateNote(date, title, content, parentNoteId); - } else if (action === 'find_notes_with_date_range') { - return await this.findNotesWithDateRange(dateStart, dateEnd); - } else if (action === 'get_daily_note') { - return await this.getDailyNote(date); - } else { - return `Error: Unsupported action "${action}". Supported actions are: find_date_notes, create_date_note, find_notes_with_date_range, get_daily_note`; - } - } catch (error: any) { - log.error(`Error executing calendar_integration tool: ${error.message || String(error)}`); - return `Error: ${error.message || String(error)}`; - } - } - - /** - * Find notes related to a specific date - */ - private async findDateNotes(date?: string): Promise { - if (!date) { - // If no date is provided, use today's date - const today = new Date(); - date = today.toISOString().split('T')[0]; - log.info(`No date specified, using today's date: ${date}`); - } - - try { - // Validate date format - if (!this.isValidDate(date)) { - return { - success: false, - message: `Invalid date format. Please use YYYY-MM-DD format.` - }; - } - - log.info(`Finding notes related to date: ${date}`); - - // Get notes with dateNote attribute matching this date - const notesWithDateAttribute = this.getNotesWithDateAttribute(date); - log.info(`Found ${notesWithDateAttribute.length} notes with date attribute for ${date}`); - - // Get year, month, day notes if they exist - const yearMonthDayNotes = await this.getYearMonthDayNotes(date); - - // Format results - return { - success: true, - date: date, - yearNote: yearMonthDayNotes.yearNote ? { - noteId: yearMonthDayNotes.yearNote.noteId, - title: yearMonthDayNotes.yearNote.title - } : null, - monthNote: yearMonthDayNotes.monthNote ? { - noteId: yearMonthDayNotes.monthNote.noteId, - title: yearMonthDayNotes.monthNote.title - } : null, - dayNote: yearMonthDayNotes.dayNote ? { - noteId: yearMonthDayNotes.dayNote.noteId, - title: yearMonthDayNotes.dayNote.title - } : null, - relatedNotes: notesWithDateAttribute.map(note => ({ - noteId: note.noteId, - title: note.title, - type: note.type - })), - message: `Found ${notesWithDateAttribute.length} notes related to date ${date}` - }; - } catch (error: any) { - log.error(`Error finding date notes: ${error.message || String(error)}`); - throw error; - } - } - - /** - * Create a new note associated with a date - */ - private async createDateNote(date?: string, title?: string, content?: string, parentNoteId?: string): Promise { - if (!date) { - // If no date is provided, use today's date - const today = new Date(); - date = today.toISOString().split('T')[0]; - log.info(`No date specified, using today's date: ${date}`); - } - - // Validate date format - if (!this.isValidDate(date)) { - return { - success: false, - message: `Invalid date format. Please use YYYY-MM-DD format.` - }; - } - - if (!title) { - title = `Note for ${date}`; - } - - if (!content) { - content = `

Date note created for ${date}

`; - } - - try { - log.info(`Creating new date note for ${date} with title "${title}"`); - - // If no parent is specified, try to find appropriate date container - if (!parentNoteId) { - // Get or create day note to use as parent - const dateComponents = this.parseDateString(date); - if (!dateComponents) { - return { - success: false, - message: `Invalid date format. Please use YYYY-MM-DD format.` - }; - } - - // Use the date string directly with getDayNote - const dayNote = await dateNotes.getDayNote(date); - - if (dayNote) { - parentNoteId = dayNote.noteId; - log.info(`Using day note ${dayNote.title} (${parentNoteId}) as parent`); - } else { - // Use root if day note couldn't be found/created - parentNoteId = 'root'; - log.info(`Could not find/create day note, using root as parent`); - } - } - - // Validate parent note exists - const parent = becca.notes[parentNoteId]; - if (!parent) { - return { - success: false, - message: `Parent note with ID ${parentNoteId} not found. Please specify a valid parent note ID.` - }; - } - - // Create the new note - const createStartTime = Date.now(); - const result = notes.createNewNote({ - parentNoteId: parent.noteId, - title: title, - content: content, - type: 'text' as const, - mime: 'text/html' - }); - const noteId = result.note.noteId; - const createDuration = Date.now() - createStartTime; - - if (!noteId) { - return { - success: false, - message: `Failed to create date note. An unknown error occurred.` - }; - } - - log.info(`Created new note with ID ${noteId} in ${createDuration}ms`); - - // Add dateNote attribute with the specified date - const attrStartTime = Date.now(); - await attributes.createLabel(noteId, 'dateNote', date); - const attrDuration = Date.now() - attrStartTime; - - log.info(`Added dateNote=${date} attribute in ${attrDuration}ms`); - - // Return the new note information - return { - success: true, - noteId: noteId, - date: date, - title: title, - message: `Created new date note "${title}" for ${date}` - }; - } catch (error: any) { - log.error(`Error creating date note: ${error.message || String(error)}`); - throw error; - } - } - - /** - * Find notes with date attributes in a specified range - */ - private async findNotesWithDateRange(dateStart?: string, dateEnd?: string): Promise { - if (!dateStart || !dateEnd) { - return { - success: false, - message: `Both dateStart and dateEnd are required for find_notes_with_date_range action.` - }; - } - - // Validate date formats - if (!this.isValidDate(dateStart) || !this.isValidDate(dateEnd)) { - return { - success: false, - message: `Invalid date format. Please use YYYY-MM-DD format.` - }; - } - - try { - log.info(`Finding notes with date attributes in range ${dateStart} to ${dateEnd}`); - - // Get all notes with dateNote attribute - const allNotes = this.getAllNotesWithDateAttribute(); - - // Filter by date range - const startDate = new Date(dateStart); - const endDate = new Date(dateEnd); - - const filteredNotes = allNotes.filter(note => { - const dateAttr = note.getOwnedAttributes() - .find((attr: any) => attr.name === 'dateNote'); - - if (dateAttr && dateAttr.value) { - const noteDate = new Date(dateAttr.value); - return noteDate >= startDate && noteDate <= endDate; - } - - return false; - }); - - log.info(`Found ${filteredNotes.length} notes in date range`); - - // Sort notes by date - filteredNotes.sort((a, b) => { - const aDateAttr = a.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote'); - const bDateAttr = b.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote'); - - if (aDateAttr && bDateAttr) { - const aDate = new Date(aDateAttr.value); - const bDate = new Date(bDateAttr.value); - return aDate.getTime() - bDate.getTime(); - } - - return 0; - }); - - // Format results - return { - success: true, - dateStart: dateStart, - dateEnd: dateEnd, - noteCount: filteredNotes.length, - notes: filteredNotes.map(note => { - const dateAttr = note.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote'); - return { - noteId: note.noteId, - title: note.title, - type: note.type, - date: dateAttr ? dateAttr.value : null - }; - }), - message: `Found ${filteredNotes.length} notes in date range ${dateStart} to ${dateEnd}` - }; - } catch (error: any) { - log.error(`Error finding notes in date range: ${error.message || String(error)}`); - throw error; - } - } - - /** - * Get or create a daily note for a specific date - */ - private async getDailyNote(date?: string): Promise { - if (!date) { - // If no date is provided, use today's date - const today = new Date(); - date = today.toISOString().split('T')[0]; - log.info(`No date specified, using today's date: ${date}`); - } - - // Validate date format - if (!this.isValidDate(date)) { - return { - success: false, - message: `Invalid date format. Please use YYYY-MM-DD format.` - }; - } - - try { - log.info(`Getting daily note for ${date}`); - - // Get or create day note - directly pass the date string - const startTime = Date.now(); - const dayNote = await dateNotes.getDayNote(date); - const duration = Date.now() - startTime; - - if (!dayNote) { - return { - success: false, - message: `Could not find or create daily note for ${date}` - }; - } - - log.info(`Retrieved/created daily note for ${date} in ${duration}ms`); - - // Get parent month and year notes - const yearStr = date.substring(0, 4); - const monthStr = date.substring(0, 7); - - const monthNote = await dateNotes.getMonthNote(monthStr); - const yearNote = await dateNotes.getYearNote(yearStr); - - // Return the note information - return { - success: true, - date: date, - dayNote: { - noteId: dayNote.noteId, - title: dayNote.title, - content: await dayNote.getContent() - }, - monthNote: monthNote ? { - noteId: monthNote.noteId, - title: monthNote.title - } : null, - yearNote: yearNote ? { - noteId: yearNote.noteId, - title: yearNote.title - } : null, - message: `Retrieved daily note for ${date}` - }; - } catch (error: any) { - log.error(`Error getting daily note: ${error.message || String(error)}`); - throw error; - } - } - - /** - * Helper method to get notes with a specific date attribute - */ - private getNotesWithDateAttribute(date: string): any[] { - // Find notes with matching dateNote attribute - return attributes.getNotesWithLabel('dateNote', date) || []; - } - - /** - * Helper method to get all notes with any date attribute - */ - private getAllNotesWithDateAttribute(): any[] { - // Find all notes with dateNote attribute - return attributes.getNotesWithLabel('dateNote') || []; - } - - /** - * Helper method to get year, month, and day notes for a date - */ - private async getYearMonthDayNotes(date: string): Promise<{ - yearNote: any | null; - monthNote: any | null; - dayNote: any | null; - }> { - if (!this.isValidDate(date)) { - return { yearNote: null, monthNote: null, dayNote: null }; - } - - // Extract the year and month from the date string - const yearStr = date.substring(0, 4); - const monthStr = date.substring(0, 7); - - // Use the dateNotes service to get the notes - const yearNote = await dateNotes.getYearNote(yearStr); - const monthNote = await dateNotes.getMonthNote(monthStr); - const dayNote = await dateNotes.getDayNote(date); - - return { yearNote, monthNote, dayNote }; - } - - /** - * Helper method to validate date string format - */ - private isValidDate(dateString: string): boolean { - const regex = /^\d{4}-\d{2}-\d{2}$/; - - if (!regex.test(dateString)) { - return false; - } - - const date = new Date(dateString); - return date.toString() !== 'Invalid Date'; - } - - /** - * Helper method to parse date string into components - */ - private parseDateString(dateString: string): { year: number; month: number; day: number } | null { - if (!this.isValidDate(dateString)) { - return null; - } - - const [yearStr, monthStr, dayStr] = dateString.split('-'); - - return { - year: parseInt(yearStr, 10), - month: parseInt(monthStr, 10), - day: parseInt(dayStr, 10) - }; - } -} diff --git a/apps/server/src/services/llm/tools/consolidated/manage_note_tool.spec.ts b/apps/server/src/services/llm/tools/consolidated/manage_note_tool.spec.ts index 505620c6f..30a9273ea 100644 --- a/apps/server/src/services/llm/tools/consolidated/manage_note_tool.spec.ts +++ b/apps/server/src/services/llm/tools/consolidated/manage_note_tool.spec.ts @@ -31,6 +31,12 @@ vi.mock('../../../attributes.js', () => ({ } })); +vi.mock('../../../cloning.js', () => ({ + default: { + cloneNoteToParentNote: vi.fn() + } +})); + describe('ManageNoteTool', () => { let tool: ManageNoteTool; @@ -58,6 +64,8 @@ describe('ManageNoteTool', () => { expect(action.enum).toContain('create'); expect(action.enum).toContain('update'); expect(action.enum).toContain('delete'); + expect(action.enum).toContain('move'); + expect(action.enum).toContain('clone'); expect(action.enum).toContain('add_attribute'); expect(action.enum).toContain('remove_attribute'); expect(action.enum).toContain('add_relation'); @@ -67,6 +75,34 @@ describe('ManageNoteTool', () => { it('should require action parameter', () => { expect(tool.definition.function.parameters.required).toContain('action'); }); + + it('should have all 17 Trilium note types in note_type enum', () => { + const noteType = tool.definition.function.parameters.properties.note_type; + expect(noteType).toBeDefined(); + expect(noteType.enum).toBeDefined(); + expect(noteType.enum).toHaveLength(17); + + // Verify all official Trilium note types are present + const expectedTypes = [ + 'text', 'code', 'file', 'image', 'search', 'noteMap', + 'relationMap', 'launcher', 'doc', 'contentWidget', 'render', + 'canvas', 'mermaid', 'book', 'webView', 'mindMap', 'aiChat' + ]; + + for (const type of expectedTypes) { + expect(noteType.enum).toContain(type); + } + }); + + it('should have default values for optional enum parameters', () => { + const noteType = tool.definition.function.parameters.properties.note_type; + expect(noteType.default).toBe('text'); + expect(noteType.enum).toContain(noteType.default); + + const updateMode = tool.definition.function.parameters.properties.update_mode; + expect(updateMode.default).toBe('replace'); + expect(updateMode.enum).toContain(updateMode.default); + }); }); describe('read action', () => { @@ -214,6 +250,62 @@ describe('ManageNoteTool', () => { expect.objectContaining({ parentNoteId: 'root' }) ); }); + + it('should validate content size limit', async () => { + const result = await tool.execute({ + action: 'create', + title: 'Test Note', + content: 'x'.repeat(10_000_001) // Exceeds 10MB limit + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('exceeds maximum size of 10MB'); + expect(result).toContain('Consider splitting into multiple notes'); + }); + + it('should validate title length limit', async () => { + const result = await tool.execute({ + action: 'create', + title: 'x'.repeat(201), // Exceeds 200 char limit + content: 'Test content' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('exceeds maximum length of 200 characters'); + expect(result).toContain('Please shorten the title'); + }); + + it('should accept all valid note types', async () => { + const notes = await import('../../../notes.js'); + const becca = await import('../../../../becca/becca.js'); + + const mockRoot = { + noteId: 'root', + title: 'Root' + }; + vi.mocked(becca.default.getNote).mockReturnValue(mockRoot as any); + + const mockNewNote = { noteId: 'new123', title: 'New Note' }; + vi.mocked(notes.default.createNewNote).mockReturnValue({ note: mockNewNote } as any); + + const validTypes = [ + 'text', 'code', 'file', 'image', 'search', 'noteMap', + 'relationMap', 'launcher', 'doc', 'contentWidget', 'render', + 'canvas', 'mermaid', 'book', 'webView', 'mindMap', 'aiChat' + ]; + + for (const noteType of validTypes) { + const result = await tool.execute({ + action: 'create', + title: `Note of type ${noteType}`, + content: 'Test content', + note_type: noteType + }) as any; + + expect(result.success).toBe(true); + expect(result.type).toBe(noteType); + } + }); }); describe('update action', () => { @@ -429,6 +521,192 @@ describe('ManageNoteTool', () => { }); }); + describe('move action', () => { + it('should move note successfully', async () => { + const mockNote = { + noteId: 'note123', + title: 'Note to Move' + }; + + const mockParent = { + noteId: 'parent123', + title: 'New Parent' + }; + + const becca = await import('../../../../becca/becca.js'); + const cloningService = await import('../../../cloning.js'); + + vi.mocked(becca.default.notes)['note123'] = mockNote as any; + vi.mocked(becca.default.notes)['parent123'] = mockParent as any; + vi.mocked(cloningService.default.cloneNoteToParentNote).mockReturnValue({ + branchId: 'branch123' + } as any); + + const result = await tool.execute({ + action: 'move', + note_id: 'note123', + parent_note_id: 'parent123' + }) as any; + + expect(result.success).toBe(true); + expect(result.noteId).toBe('note123'); + expect(result.newParentId).toBe('parent123'); + expect(result.branchId).toBe('branch123'); + expect(cloningService.default.cloneNoteToParentNote).toHaveBeenCalledWith( + 'note123', + 'parent123' + ); + }); + + it('should require note_id for move', async () => { + const result = await tool.execute({ + action: 'move', + parent_note_id: 'parent123' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('note_id is required'); + }); + + it('should require parent_note_id for move', async () => { + const result = await tool.execute({ + action: 'move', + note_id: 'note123' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('parent_note_id is required'); + }); + + it('should return error for non-existent note in move', async () => { + const becca = await import('../../../../becca/becca.js'); + vi.mocked(becca.default.notes)['note123'] = undefined as any; + + const result = await tool.execute({ + action: 'move', + note_id: 'note123', + parent_note_id: 'parent123' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('not found'); + }); + + it('should return error for non-existent parent in move', async () => { + const mockNote = { + noteId: 'note123', + title: 'Note to Move' + }; + + const becca = await import('../../../../becca/becca.js'); + vi.mocked(becca.default.notes)['note123'] = mockNote as any; + vi.mocked(becca.default.notes)['parent123'] = undefined as any; + + const result = await tool.execute({ + action: 'move', + note_id: 'note123', + parent_note_id: 'parent123' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('Parent note'); + expect(result).toContain('not found'); + }); + }); + + describe('clone action', () => { + it('should clone note successfully', async () => { + const mockNote = { + noteId: 'note123', + title: 'Note to Clone' + }; + + const mockParent = { + noteId: 'parent123', + title: 'Target Parent' + }; + + const becca = await import('../../../../becca/becca.js'); + const cloningService = await import('../../../cloning.js'); + + vi.mocked(becca.default.notes)['note123'] = mockNote as any; + vi.mocked(becca.default.notes)['parent123'] = mockParent as any; + vi.mocked(cloningService.default.cloneNoteToParentNote).mockReturnValue({ + branchId: 'branch456' + } as any); + + const result = await tool.execute({ + action: 'clone', + note_id: 'note123', + parent_note_id: 'parent123' + }) as any; + + expect(result.success).toBe(true); + expect(result.sourceNoteId).toBe('note123'); + expect(result.parentNoteId).toBe('parent123'); + expect(result.branchId).toBe('branch456'); + expect(cloningService.default.cloneNoteToParentNote).toHaveBeenCalledWith( + 'note123', + 'parent123' + ); + }); + + it('should require note_id for clone', async () => { + const result = await tool.execute({ + action: 'clone', + parent_note_id: 'parent123' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('note_id is required'); + }); + + it('should require parent_note_id for clone', async () => { + const result = await tool.execute({ + action: 'clone', + note_id: 'note123' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('parent_note_id is required'); + }); + + it('should return error for non-existent note in clone', async () => { + const becca = await import('../../../../becca/becca.js'); + vi.mocked(becca.default.notes)['note123'] = undefined as any; + + const result = await tool.execute({ + action: 'clone', + note_id: 'note123', + parent_note_id: 'parent123' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('not found'); + }); + + it('should return error for non-existent parent in clone', async () => { + const mockNote = { + noteId: 'note123', + title: 'Note to Clone' + }; + + const becca = await import('../../../../becca/becca.js'); + vi.mocked(becca.default.notes)['note123'] = mockNote as any; + vi.mocked(becca.default.notes)['parent123'] = undefined as any; + + const result = await tool.execute({ + action: 'clone', + note_id: 'note123', + parent_note_id: 'parent123' + }); + + expect(typeof result).toBe('string'); + expect(result).toContain('Parent note'); + expect(result).toContain('not found'); + }); + }); + describe('error handling', () => { it('should handle unknown action', async () => { const result = await tool.execute({ diff --git a/apps/server/src/services/llm/tools/consolidated/manage_note_tool.ts b/apps/server/src/services/llm/tools/consolidated/manage_note_tool.ts index 124ec48a3..aa6cc4726 100644 --- a/apps/server/src/services/llm/tools/consolidated/manage_note_tool.ts +++ b/apps/server/src/services/llm/tools/consolidated/manage_note_tool.ts @@ -18,6 +18,7 @@ import log from '../../../log.js'; import becca from '../../../../becca/becca.js'; import notes from '../../../notes.js'; import attributes from '../../../attributes.js'; +import cloningService from '../../../cloning.js'; import type { BNote } from '../../../backend_script_entrypoint.js'; /** @@ -28,6 +29,8 @@ type NoteAction = | 'create' | 'update' | 'delete' + | 'move' + | 'clone' | 'add_attribute' | 'remove_attribute' | 'add_relation' @@ -59,14 +62,14 @@ export const manageNoteToolDefinition: Tool = { type: 'function', function: { name: 'manage_note', - description: 'Unified interface for all note operations: read, create, update, and manage attributes/relations. Replaces separate read, create, update, attribute, and relationship tools.', + description: 'Unified interface for all note operations: read, create, update, delete, move, clone, and manage attributes/relations. Replaces separate read, create, update, attribute, and relationship tools.', parameters: { type: 'object', properties: { action: { type: 'string', description: 'Operation to perform', - enum: ['read', 'create', 'update', 'delete', 'add_attribute', 'remove_attribute', 'add_relation', 'remove_relation', 'list_attributes', 'list_relations'] + enum: ['read', 'create', 'update', 'delete', 'move', 'clone', 'add_attribute', 'remove_attribute', 'add_relation', 'remove_relation', 'list_attributes', 'list_relations'] }, note_id: { type: 'string', @@ -86,8 +89,9 @@ export const manageNoteToolDefinition: Tool = { }, note_type: { type: 'string', - description: 'Note type: text, code, file, image, etc.', - enum: ['text', 'code', 'file', 'image', 'search', 'relation-map', 'book', 'mermaid', 'canvas'] + description: 'Note type (default: text). User-creatable: text, code, book, canvas, mermaid, mindMap, relationMap, webView, render. System types: file, image, search, noteMap, launcher, doc, contentWidget, aiChat.', + enum: ['text', 'code', 'book', 'canvas', 'mermaid', 'mindMap', 'relationMap', 'webView', 'render', 'file', 'image', 'search', 'noteMap', 'launcher', 'doc', 'contentWidget', 'aiChat'], + default: 'text' }, mime: { type: 'string', @@ -95,8 +99,9 @@ export const manageNoteToolDefinition: Tool = { }, update_mode: { type: 'string', - description: 'Content update mode: replace, append, or prepend', - enum: ['replace', 'append', 'prepend'] + description: 'Content update mode (default: replace)', + enum: ['replace', 'append', 'prepend'], + default: 'replace' }, attribute_name: { type: 'string', @@ -169,6 +174,10 @@ export class ManageNoteTool implements ToolHandler { return await this.updateNote(args); case 'delete': return await this.deleteNote(args); + case 'move': + return await this.moveNote(args); + case 'clone': + return await this.cloneNote(args); case 'add_attribute': return await this.addAttribute(args); case 'remove_attribute': @@ -252,12 +261,23 @@ export class ManageNoteTool implements ToolHandler { return 'Error: content is required for create action'; } - // Validate parent note + // Business logic validations (not schema validations - those are enforced by LLM provider) + const MAX_CONTENT_SIZE = 10_000_000; // 10MB + if (content.length > MAX_CONTENT_SIZE) { + return `Error: Content exceeds maximum size of 10MB (${content.length} bytes). Consider splitting into multiple notes.`; + } + + const MAX_TITLE_LENGTH = 200; + if (title.length > MAX_TITLE_LENGTH) { + return `Error: Title exceeds maximum length of 200 characters. Current length: ${title.length}. Please shorten the title.`; + } + + // Validate parent note exists (business logic constraint) let parent: BNote | null = null; if (parent_note_id) { parent = becca.notes[parent_note_id]; if (!parent) { - return `Error: Parent note with ID ${parent_note_id} not found`; + return `Error: Parent note ${parent_note_id} not found. Use smart_search to find valid parent notes.`; } } else { parent = becca.getNote('root'); @@ -392,6 +412,95 @@ export class ManageNoteTool implements ToolHandler { }; } + /** + * Move a note to a new parent (creates a new branch) + * In Trilium, notes can have multiple parents, so "moving" means creating a new branch + */ + private async moveNote(args: { note_id?: string; parent_note_id?: string }): Promise { + const { note_id, parent_note_id } = args; + + if (!note_id) { + return 'Error: note_id is required for move action'; + } + + if (!parent_note_id) { + return 'Error: parent_note_id is required for move action'; + } + + const note = becca.notes[note_id]; + if (!note) { + return `Error: Note with ID ${note_id} not found`; + } + + const parentNote = becca.notes[parent_note_id]; + if (!parentNote) { + return `Error: Parent note with ID ${parent_note_id} not found`; + } + + log.info(`Moving note "${note.title}" to parent "${parentNote.title}"`); + + // Clone note to new parent (this creates a new branch) + const startTime = Date.now(); + const cloneResult = cloningService.cloneNoteToParentNote(note_id, parent_note_id); + const duration = Date.now() - startTime; + + log.info(`Note moved in ${duration}ms - new branch ID: ${cloneResult.branchId}`); + + return { + success: true, + noteId: note.noteId, + title: note.title, + newParentId: parent_note_id, + newParentTitle: parentNote.title, + branchId: cloneResult.branchId, + message: `Note "${note.title}" moved to "${parentNote.title}" (notes can have multiple parents in Trilium)` + }; + } + + /** + * Clone a note (deep copy with all children) + */ + private async cloneNote(args: { note_id?: string; parent_note_id?: string }): Promise { + const { note_id, parent_note_id } = args; + + if (!note_id) { + return 'Error: note_id is required for clone action'; + } + + if (!parent_note_id) { + return 'Error: parent_note_id is required for clone action'; + } + + const note = becca.notes[note_id]; + if (!note) { + return `Error: Note with ID ${note_id} not found`; + } + + const parentNote = becca.notes[parent_note_id]; + if (!parentNote) { + return `Error: Parent note with ID ${parent_note_id} not found`; + } + + log.info(`Cloning note "${note.title}" to parent "${parentNote.title}"`); + + // Clone note to new parent + const startTime = Date.now(); + const cloneResult = cloningService.cloneNoteToParentNote(note_id, parent_note_id); + const duration = Date.now() - startTime; + + log.info(`Note cloned in ${duration}ms - new branch ID: ${cloneResult.branchId}`); + + return { + success: true, + sourceNoteId: note.noteId, + sourceTitle: note.title, + parentNoteId: parent_note_id, + parentTitle: parentNote.title, + branchId: cloneResult.branchId, + message: `Note "${note.title}" cloned to "${parentNote.title}"` + }; + } + /** * Add an attribute to a note */ @@ -749,10 +858,18 @@ export class ManageNoteTool implements ToolHandler { 'file': 'application/octet-stream', 'image': 'image/png', 'search': 'application/json', - 'relation-map': 'application/json', - 'book': 'text/html', + 'noteMap': '', + 'relationMap': 'application/json', + 'launcher': '', + 'doc': '', + 'contentWidget': '', + 'render': '', + 'canvas': 'application/json', 'mermaid': 'text/mermaid', - 'canvas': 'application/json' + 'book': 'text/html', + 'webView': '', + 'mindMap': 'application/json', + 'aiChat': 'application/json' }; return mimeMap[noteType] || 'text/html'; diff --git a/apps/server/src/services/llm/tools/tool_initializer_v2.ts b/apps/server/src/services/llm/tools/tool_initializer_v2.ts index d303ed554..342ee04c2 100644 --- a/apps/server/src/services/llm/tools/tool_initializer_v2.ts +++ b/apps/server/src/services/llm/tools/tool_initializer_v2.ts @@ -25,7 +25,6 @@ import toolRegistry from './tool_registry.js'; import { SmartSearchTool } from './consolidated/smart_search_tool.js'; import { ManageNoteTool } from './consolidated/manage_note_tool.js'; import { NavigateHierarchyTool } from './consolidated/navigate_hierarchy_tool.js'; -import { CalendarIntegrationTool } from './calendar_integration_tool.js'; import log from '../../log.js'; /** @@ -43,18 +42,17 @@ export async function initializeConsolidatedTools(): Promise { try { log.info('Initializing consolidated LLM tools (V2)...'); - // Register the 4 consolidated tools + // Register the 3 consolidated tools toolRegistry.registerTool(new SmartSearchTool()); // Replaces: search_notes, keyword_search, attribute_search, search_suggestion - toolRegistry.registerTool(new ManageNoteTool()); // Replaces: read_note, note_creation, note_update, attribute_manager, relationship + toolRegistry.registerTool(new ManageNoteTool()); // Replaces: read_note, note_creation, note_update, attribute_manager, relationship, calendar (via attributes) toolRegistry.registerTool(new NavigateHierarchyTool()); // New: tree navigation capability - toolRegistry.registerTool(new CalendarIntegrationTool()); // Enhanced: calendar operations // Log registered tools const toolCount = toolRegistry.getAllTools().length; const toolNames = toolRegistry.getAllTools().map(tool => tool.definition.function.name).join(', '); log.info(`Successfully registered ${toolCount} consolidated LLM tools: ${toolNames}`); - log.info('Tool consolidation: 12 tools → 4 tools (67% reduction, ~600 tokens saved)'); + log.info('Tool consolidation: 12 tools → 3 tools (75% reduction, ~725 tokens saved)'); } catch (error: unknown) { const errorMessage = isError(error) ? error.message : String(error); log.error(`Error initializing consolidated LLM tools: ${errorMessage}`); @@ -77,9 +75,9 @@ export function getConsolidationInfo(): { } { return { version: 'v2', - toolCount: 4, + toolCount: 3, consolidatedFrom: 12, - tokenSavings: 600, // Estimated + tokenSavings: 725, // Estimated (increased from 600 with calendar removal) tools: [ { name: 'smart_search', @@ -87,15 +85,11 @@ export function getConsolidationInfo(): { }, { name: 'manage_note', - replaces: ['read_note', 'create_note', 'update_note', 'manage_attributes', 'manage_relationships', 'note_summarization', 'content_extraction'] + replaces: ['read_note', 'create_note', 'update_note', 'delete_note', 'move_note', 'clone_note', 'manage_attributes', 'manage_relationships', 'note_summarization', 'content_extraction', 'calendar_integration (via attributes)'] }, { name: 'navigate_hierarchy', replaces: ['(new capability - no replacement)'] - }, - { - name: 'calendar_integration', - replaces: ['calendar_integration (enhanced)'] } ] };