mirror of
https://github.com/TriliumNext/Trilium.git
synced 2025-12-10 03:53:37 -06:00
feat(llm): migrate the calendar tool into the manage_note tool
This commit is contained in:
parent
4a239248b1
commit
5710becf05
@ -505,7 +505,7 @@ export class ChatService {
|
||||
async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise<ChatResponse> {
|
||||
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 {
|
||||
|
||||
@ -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<string | object> {
|
||||
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<object> {
|
||||
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<object> {
|
||||
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 = `<p>Date note created for ${date}</p>`;
|
||||
}
|
||||
|
||||
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<object> {
|
||||
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<object> {
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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<string | object> {
|
||||
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<string | object> {
|
||||
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';
|
||||
|
||||
@ -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<void> {
|
||||
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)']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user