add some more useful tools

CLOSER....

works?
This commit is contained in:
perf3ct 2025-04-07 21:57:18 +00:00
parent 26b1b08129
commit 7725b924e9
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
14 changed files with 2795 additions and 272 deletions

View File

@ -34,6 +34,7 @@ export interface ChatCompletionOptions {
stream?: boolean; // Whether to stream the response
enableTools?: boolean; // Whether to enable tool calling
tools?: any[]; // Tools to provide to the LLM
useAdvancedContext?: boolean; // Whether to use advanced context enrichment
}
export interface ChatResponse {

View File

@ -1,9 +1,21 @@
import type { Message, ChatCompletionOptions } from './ai_interface.js';
import type { Message, ChatCompletionOptions, ChatResponse } from './ai_interface.js';
import chatStorageService from './chat_storage_service.js';
import log from '../log.js';
import { CONTEXT_PROMPTS, ERROR_PROMPTS } from './constants/llm_prompt_constants.js';
import { ChatPipeline } from './pipeline/chat_pipeline.js';
import type { ChatPipelineConfig, StreamCallback } from './pipeline/interfaces.js';
import aiServiceManager from './ai_service_manager.js';
import type { ChatPipelineInput } from './pipeline/interfaces.js';
// Update the ChatCompletionOptions interface to include the missing properties
// TODO fix
declare module './ai_interface.js' {
interface ChatCompletionOptions {
pipeline?: string;
noteId?: string;
useAdvancedContext?: boolean;
}
}
export interface ChatSession {
id: string;
@ -365,7 +377,7 @@ export class ChatService {
const pipeline = this.getPipeline(pipelineType);
return pipeline.getMetrics();
}
/**
* Reset pipeline metrics
*/
@ -398,8 +410,62 @@ export class ChatService {
// Take first 30 chars if too long
return firstLine.substring(0, 27) + '...';
}
/**
* Generate a chat completion with a sequence of messages
* @param messages Messages array to send to the AI provider
* @param options Chat completion options
*/
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(`Tool support enabled: ${options.enableTools !== false}`);
try {
// Get AI service
const service = await aiServiceManager.getService();
if (!service) {
throw new Error('No AI service available');
}
log.info(`Using AI service: ${service.getName()}`);
// Prepare query extraction
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
const query = lastUserMessage ? lastUserMessage.content : undefined;
// For advanced context processing, use the pipeline
if (options.useAdvancedContext && query) {
log.info(`Using chat pipeline for advanced context with query: ${query.substring(0, 50)}...`);
// Create a pipeline input with the query and messages
const pipelineInput: ChatPipelineInput = {
messages,
options,
query,
noteId: options.noteId
};
// Execute the pipeline
const pipeline = this.getPipeline(options.pipeline);
const response = await pipeline.execute(pipelineInput);
log.info(`Pipeline execution complete, response contains tools: ${response.tool_calls ? 'yes' : 'no'}`);
if (response.tool_calls) {
log.info(`Tool calls in pipeline response: ${response.tool_calls.length}`);
}
return response;
}
// If not using advanced context, use direct service call
return await service.generateChatCompletion(messages, options);
} catch (error: any) {
console.error('Error in generateChatCompletion:', error);
throw error;
}
}
}
// Singleton instance
const chatService = new ChatService();
export default chatService;
export default chatService;

View File

@ -19,6 +19,13 @@ export interface LLMServiceInterface {
stream?: boolean;
systemPrompt?: string;
}): Promise<ChatResponse>;
/**
* Generate search queries by decomposing a complex query into simpler ones
* @param query The original user query to decompose
* @returns An array of decomposed search queries
*/
generateSearchQueries?(query: string): Promise<string[]>;
}
/**

View File

@ -1,5 +1,5 @@
import type { ChatPipelineInput, ChatPipelineConfig, PipelineMetrics, StreamCallback } from './interfaces.js';
import type { ChatResponse, StreamChunk } from '../ai_interface.js';
import type { ChatResponse, StreamChunk, Message } from '../ai_interface.js';
import { ContextExtractionStage } from './stages/context_extraction_stage.js';
import { SemanticContextExtractionStage } from './stages/semantic_context_extraction_stage.js';
import { AgentToolsContextStage } from './stages/agent_tools_context_stage.js';
@ -12,6 +12,7 @@ import { VectorSearchStage } from './stages/vector_search_stage.js';
import toolRegistry from '../tools/tool_registry.js';
import toolInitializer from '../tools/tool_initializer.js';
import log from '../../log.js';
import type { LLMServiceInterface } from '../interfaces/agent_tool_interfaces.js';
/**
* Pipeline for managing the entire chat flow
@ -80,6 +81,7 @@ export class ChatPipeline {
* This is the main entry point that orchestrates all pipeline stages
*/
async execute(input: ChatPipelineInput): Promise<ChatResponse> {
log.info(`========== STARTING CHAT PIPELINE ==========`);
log.info(`Executing chat pipeline with ${input.messages.length} messages`);
const startTime = Date.now();
this.metrics.totalExecutions++;
@ -113,89 +115,107 @@ export class ChatPipeline {
// First, select the appropriate model based on query complexity and content length
const modelSelectionStartTime = Date.now();
log.info(`========== MODEL SELECTION ==========`);
const modelSelection = await this.stages.modelSelection.execute({
options: input.options,
query: input.query,
contentLength
});
this.updateStageMetrics('modelSelection', modelSelectionStartTime);
log.info(`Selected model: ${modelSelection.options.model || 'default'}, enableTools: ${modelSelection.options.enableTools}`);
// Determine if we should use tools or semantic context
const useTools = modelSelection.options.enableTools === true;
const useEnhancedContext = input.options?.useAdvancedContext === true;
// Determine which pipeline flow to use
let context: string | undefined;
// Early return if we don't have a query or enhanced context is disabled
if (!input.query || !useEnhancedContext) {
log.info(`========== SIMPLE QUERY MODE ==========`);
log.info('Enhanced context disabled or no query provided, skipping context enrichment');
// For context-aware chats, get the appropriate context
if (input.noteId && input.query) {
const contextStartTime = Date.now();
if (input.showThinking) {
// Get enhanced context with agent tools if thinking is enabled
const agentContext = await this.stages.agentToolsContext.execute({
noteId: input.noteId,
query: input.query,
showThinking: input.showThinking
});
context = agentContext.context;
this.updateStageMetrics('agentToolsContext', contextStartTime);
} else if (!useTools) {
// Only get semantic context if tools are NOT enabled
// When tools are enabled, we'll let the LLM request context via tools instead
log.info('Getting semantic context for note using pipeline stages');
// First use the vector search stage to find relevant notes
const vectorSearchStartTime = Date.now();
log.info(`Executing vector search stage for query: "${input.query?.substring(0, 50)}..."`);
const vectorSearchResult = await this.stages.vectorSearch.execute({
query: input.query || '',
noteId: input.noteId,
options: {
maxResults: 10,
useEnhancedQueries: true,
threshold: 0.6
}
});
this.updateStageMetrics('vectorSearch', vectorSearchStartTime);
log.info(`Vector search found ${vectorSearchResult.searchResults.length} relevant notes`);
// Then pass to the semantic context stage to build the formatted context
const semanticContext = await this.stages.semanticContextExtraction.execute({
noteId: input.noteId,
query: input.query,
messages: input.messages
});
context = semanticContext.context;
this.updateStageMetrics('semanticContextExtraction', contextStartTime);
} else {
log.info('Tools are enabled - using minimal direct context to avoid race conditions');
// Get context from current note directly without semantic search
if (input.noteId) {
try {
const contextExtractor = new (await import('../../llm/context/index.js')).ContextExtractor();
// Just get the direct content of the current note
context = await contextExtractor.extractContext(input.noteId, {
includeContent: true,
includeParents: true,
includeChildren: true,
includeLinks: true,
includeSimilar: false // Skip semantic search to avoid race conditions
});
log.info(`Direct context extracted (${context.length} chars) without semantic search`);
} catch (error: any) {
log.error(`Error extracting direct context: ${error.message}`);
context = ""; // Fallback to empty context if extraction fails
}
} else {
context = ""; // No note ID, so no context
}
}
// Prepare messages without additional context
const messagePreparationStartTime = Date.now();
const preparedMessages = await this.stages.messagePreparation.execute({
messages: input.messages,
systemPrompt: input.options?.systemPrompt,
options: modelSelection.options
});
this.updateStageMetrics('messagePreparation', messagePreparationStartTime);
// Generate completion using the LLM
const llmStartTime = Date.now();
const completion = await this.stages.llmCompletion.execute({
messages: preparedMessages.messages,
options: modelSelection.options
});
this.updateStageMetrics('llmCompletion', llmStartTime);
return completion.response;
}
// Prepare messages with context and system prompt
// STAGE 1: Start with the user's query
const userQuery = input.query || '';
log.info(`========== STAGE 1: USER QUERY ==========`);
log.info(`Processing query with: question="${userQuery.substring(0, 50)}...", noteId=${input.noteId}, showThinking=${input.showThinking}`);
// STAGE 2: Perform query decomposition using the LLM
log.info(`========== STAGE 2: QUERY DECOMPOSITION ==========`);
log.info('Performing query decomposition to generate effective search queries');
const llmService = await this.getLLMService();
let searchQueries = [userQuery]; // Default to original query
if (llmService && llmService.generateSearchQueries) {
try {
const decompositionResult = await llmService.generateSearchQueries(userQuery);
if (decompositionResult && decompositionResult.length > 0) {
searchQueries = decompositionResult;
log.info(`Generated ${searchQueries.length} search queries: ${JSON.stringify(searchQueries)}`);
} else {
log.info('Query decomposition returned no results, using original query');
}
} catch (error: any) {
log.error(`Error in query decomposition: ${error.message || String(error)}`);
}
} else {
log.info('No LLM service available for query decomposition, using original query');
}
// STAGE 3: Execute vector similarity search with decomposed queries
const vectorSearchStartTime = Date.now();
log.info(`========== STAGE 3: VECTOR SEARCH ==========`);
log.info('Using VectorSearchStage pipeline component to find relevant notes');
const vectorSearchResult = await this.stages.vectorSearch.execute({
query: userQuery,
noteId: input.noteId || 'global',
options: {
maxResults: 5, // Can be adjusted
useEnhancedQueries: true,
threshold: 0.6,
llmService: llmService || undefined
}
});
this.updateStageMetrics('vectorSearch', vectorSearchStartTime);
log.info(`Vector search found ${vectorSearchResult.searchResults.length} relevant notes`);
// Extract context from search results
log.info(`========== SEMANTIC CONTEXT EXTRACTION ==========`);
const semanticContextStartTime = Date.now();
const semanticContext = await this.stages.semanticContextExtraction.execute({
noteId: input.noteId || 'global',
query: userQuery,
messages: input.messages,
searchResults: vectorSearchResult.searchResults
});
const context = semanticContext.context;
this.updateStageMetrics('semanticContextExtraction', semanticContextStartTime);
log.info(`Extracted semantic context (${context.length} chars)`);
// STAGE 4: Prepare messages with context and tool definitions for the LLM
log.info(`========== STAGE 4: MESSAGE PREPARATION ==========`);
const messagePreparationStartTime = Date.now();
const preparedMessages = await this.stages.messagePreparation.execute({
messages: input.messages,
@ -204,9 +224,7 @@ export class ChatPipeline {
options: modelSelection.options
});
this.updateStageMetrics('messagePreparation', messagePreparationStartTime);
// Generate completion using the LLM
const llmStartTime = Date.now();
log.info(`Prepared ${preparedMessages.messages.length} messages for LLM, tools enabled: ${useTools}`);
// Setup streaming handler if streaming is enabled and callback provided
const enableStreaming = this.config.enableStreaming &&
@ -218,11 +236,15 @@ export class ChatPipeline {
modelSelection.options.stream = true;
}
// STAGE 5 & 6: Handle LLM completion and tool execution loop
log.info(`========== STAGE 5: LLM COMPLETION ==========`);
const llmStartTime = Date.now();
const completion = await this.stages.llmCompletion.execute({
messages: preparedMessages.messages,
options: modelSelection.options
});
this.updateStageMetrics('llmCompletion', llmStartTime);
log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`);
// Handle streaming if enabled and available
if (enableStreaming && completion.response.stream && streamCallback) {
@ -242,123 +264,247 @@ export class ChatPipeline {
// Process any tool calls in the response
let currentMessages = preparedMessages.messages;
let currentResponse = completion.response;
let needsFollowUp = false;
let toolCallIterations = 0;
const maxToolCallIterations = this.config.maxToolCallIterations;
// Check if tools were enabled in the options
const toolsEnabled = modelSelection.options.enableTools !== false;
log.info(`========== TOOL CALL PROCESSING ==========`);
log.info(`Tools enabled: ${toolsEnabled}`);
log.info(`Tool calls in response: ${currentResponse.tool_calls ? currentResponse.tool_calls.length : 0}`);
log.info(`Current response format: ${typeof currentResponse}`);
log.info(`Response keys: ${Object.keys(currentResponse).join(', ')}`);
// Detailed tool call inspection
// Log decision points for tool execution
log.info(`========== TOOL EXECUTION DECISION ==========`);
log.info(`Tools enabled in options: ${toolsEnabled}`);
log.info(`Response provider: ${currentResponse.provider || 'unknown'}`);
log.info(`Response model: ${currentResponse.model || 'unknown'}`);
log.info(`Response has tool_calls: ${currentResponse.tool_calls ? 'true' : 'false'}`);
if (currentResponse.tool_calls) {
currentResponse.tool_calls.forEach((tool, idx) => {
log.info(`Tool call ${idx+1}: ${JSON.stringify(tool)}`);
});
log.info(`Number of tool calls: ${currentResponse.tool_calls.length}`);
log.info(`Tool calls details: ${JSON.stringify(currentResponse.tool_calls)}`);
// Check if we have a response from Ollama, which might be handled differently
if (currentResponse.provider === 'Ollama') {
log.info(`ATTENTION: Response is from Ollama - checking if tool execution path is correct`);
log.info(`Tool calls type: ${typeof currentResponse.tool_calls}`);
log.info(`First tool call name: ${currentResponse.tool_calls[0]?.function?.name || 'unknown'}`);
}
}
// Process tool calls if present and tools are enabled
// Tool execution loop
if (toolsEnabled && currentResponse.tool_calls && currentResponse.tool_calls.length > 0) {
log.info(`========== STAGE 6: TOOL EXECUTION ==========`);
log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`);
// Start tool calling loop
log.info(`Starting tool calling loop with max ${maxToolCallIterations} iterations`);
// Format tool calls for logging
log.info(`========== TOOL CALL DETAILS ==========`);
currentResponse.tool_calls.forEach((toolCall, idx) => {
log.info(`Tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
});
do {
log.info(`Tool calling iteration ${toolCallIterations + 1}`);
// Keep track of whether we're in a streaming response
const isStreaming = enableStreaming && streamCallback;
let streamingPaused = false;
// Execute tool calling stage
const toolCallingStartTime = Date.now();
const toolCallingResult = await this.stages.toolCalling.execute({
response: currentResponse,
messages: currentMessages,
options: modelSelection.options
});
this.updateStageMetrics('toolCalling', toolCallingStartTime);
// If streaming was enabled, send an update to the user
if (isStreaming && streamCallback) {
streamingPaused = true;
await streamCallback('', true); // Signal pause in streaming
await streamCallback('\n\n[Executing tools...]\n\n', false);
}
// Update state for next iteration
currentMessages = toolCallingResult.messages;
needsFollowUp = toolCallingResult.needsFollowUp;
while (toolCallIterations < maxToolCallIterations) {
toolCallIterations++;
log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`);
// Make another call to the LLM if needed
if (needsFollowUp) {
log.info(`Tool execution completed, making follow-up LLM call (iteration ${toolCallIterations + 1})...`);
// Create a copy of messages before tool execution
const previousMessages = [...currentMessages];
// Generate a new LLM response with the updated messages
const followUpStartTime = Date.now();
log.info(`Sending follow-up request to LLM with ${currentMessages.length} messages (including tool results)`);
try {
const toolCallingStartTime = Date.now();
log.info(`========== PIPELINE TOOL EXECUTION FLOW ==========`);
log.info(`About to call toolCalling.execute with ${currentResponse.tool_calls.length} tool calls`);
log.info(`Tool calls being passed to stage: ${JSON.stringify(currentResponse.tool_calls)}`);
const followUpCompletion = await this.stages.llmCompletion.execute({
const toolCallingResult = await this.stages.toolCalling.execute({
response: currentResponse,
messages: currentMessages,
options: modelSelection.options
});
this.updateStageMetrics('llmCompletion', followUpStartTime);
this.updateStageMetrics('toolCalling', toolCallingStartTime);
// Update current response for next iteration
currentResponse = followUpCompletion.response;
log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`);
// Check for more tool calls
const hasMoreToolCalls = !!(currentResponse.tool_calls && currentResponse.tool_calls.length > 0);
// Update messages with tool results
currentMessages = toolCallingResult.messages;
if (hasMoreToolCalls) {
log.info(`Follow-up response contains ${currentResponse.tool_calls?.length || 0} more tool calls`);
// Log the tool results for debugging
const toolResultMessages = currentMessages.filter(
msg => msg.role === 'tool' && !previousMessages.includes(msg)
);
log.info(`========== TOOL EXECUTION RESULTS ==========`);
toolResultMessages.forEach((msg, idx) => {
log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content.substring(0, 50)}...`);
// If streaming, show tool executions to the user
if (isStreaming && streamCallback) {
// For each tool result, format a readable message for the user
const toolName = this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '');
const formattedToolResult = `[Tool: ${toolName || 'unknown'}]\n${msg.content}\n\n`;
streamCallback(formattedToolResult, false);
}
});
// Check if we need another LLM completion for tool results
if (toolCallingResult.needsFollowUp) {
log.info(`========== TOOL FOLLOW-UP REQUIRED ==========`);
log.info('Tool execution complete, sending results back to LLM');
// Ensure messages are properly formatted
this.validateToolMessages(currentMessages);
// If streaming, show progress to the user
if (isStreaming && streamCallback) {
await streamCallback('[Generating response with tool results...]\n\n', false);
}
// Generate a new completion with the updated messages
const followUpStartTime = Date.now();
const followUpCompletion = await this.stages.llmCompletion.execute({
messages: currentMessages,
options: {
...modelSelection.options,
// Ensure tool support is still enabled for follow-up requests
enableTools: true,
// Disable streaming during tool execution follow-ups
stream: false
}
});
this.updateStageMetrics('llmCompletion', followUpStartTime);
// Update current response for the next iteration
currentResponse = followUpCompletion.response;
// Check if we need to continue the tool calling loop
if (!currentResponse.tool_calls || currentResponse.tool_calls.length === 0) {
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
log.info('No more tool calls, breaking tool execution loop');
break;
} else {
log.info(`========== ADDITIONAL TOOL CALLS DETECTED ==========`);
log.info(`Next iteration has ${currentResponse.tool_calls.length} more tool calls`);
// Log the next set of tool calls
currentResponse.tool_calls.forEach((toolCall, idx) => {
log.info(`Next tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
});
}
} else {
log.info(`Follow-up response contains no more tool calls - completing tool loop`);
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
log.info('No follow-up needed, breaking tool execution loop');
break;
}
} catch (error: any) {
log.info(`========== TOOL EXECUTION ERROR ==========`);
log.error(`Error in tool execution: ${error.message || String(error)}`);
// Add error message to the conversation if tool execution fails
currentMessages.push({
role: 'system',
content: `Error executing tool: ${error.message || String(error)}. Please try a different approach.`
});
// If streaming, show error to the user
if (isStreaming && streamCallback) {
await streamCallback(`[Tool execution error: ${error.message || 'unknown error'}]\n\n`, false);
}
// Continue loop if there are more tool calls
needsFollowUp = hasMoreToolCalls;
// Make a follow-up request to the LLM with the error information
const errorFollowUpCompletion = await this.stages.llmCompletion.execute({
messages: currentMessages,
options: modelSelection.options
});
// Update current response and break the tool loop
currentResponse = errorFollowUpCompletion.response;
break;
}
// Increment iteration counter
toolCallIterations++;
} while (needsFollowUp && toolCallIterations < maxToolCallIterations);
// If we hit max iterations but still have tool calls, log a warning
if (toolCallIterations >= maxToolCallIterations && needsFollowUp) {
log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), stopping`);
}
log.info(`Completed ${toolCallIterations} tool call iterations`);
if (toolCallIterations >= maxToolCallIterations) {
log.info(`========== MAXIMUM TOOL ITERATIONS REACHED ==========`);
log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), terminating loop`);
// Add a message to inform the LLM that we've reached the limit
currentMessages.push({
role: 'system',
content: `Maximum tool call iterations (${maxToolCallIterations}) reached. Please provide your best response with the information gathered so far.`
});
// If streaming, inform the user about iteration limit
if (isStreaming && streamCallback) {
await streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false);
}
// Make a final request to get a summary response
const finalFollowUpCompletion = await this.stages.llmCompletion.execute({
messages: currentMessages,
options: {
...modelSelection.options,
enableTools: false // Disable tools for the final response
}
});
// Update the current response
currentResponse = finalFollowUpCompletion.response;
}
// If streaming was paused for tool execution, resume it now with the final response
if (isStreaming && streamCallback && streamingPaused) {
// Resume streaming with the final response text
await streamCallback(currentResponse.text, true);
}
} else if (toolsEnabled) {
log.info(`========== NO TOOL CALLS DETECTED ==========`);
log.info(`LLM response did not contain any tool calls, skipping tool execution`);
}
// For non-streaming responses, process the final response
const processStartTime = Date.now();
const processed = await this.stages.responseProcessing.execute({
// Process the final response
log.info(`========== FINAL RESPONSE PROCESSING ==========`);
const responseProcessingStartTime = Date.now();
const processedResponse = await this.stages.responseProcessing.execute({
response: currentResponse,
options: input.options
options: modelSelection.options
});
this.updateStageMetrics('responseProcessing', processStartTime);
this.updateStageMetrics('responseProcessing', responseProcessingStartTime);
log.info(`Final response processed, returning to user (${processedResponse.text.length} chars)`);
// Combine response with processed text, using accumulated text if streamed
const finalResponse: ChatResponse = {
...currentResponse,
text: accumulatedText || processed.text
};
// Return the final response to the user
// The ResponseProcessingStage returns {text}, not {response}
// So we update our currentResponse with the processed text
currentResponse.text = processedResponse.text;
const endTime = Date.now();
const executionTime = endTime - startTime;
// Update overall average execution time
this.metrics.averageExecutionTime =
(this.metrics.averageExecutionTime * (this.metrics.totalExecutions - 1) + executionTime) /
this.metrics.totalExecutions;
log.info(`Chat pipeline completed in ${executionTime}ms`);
return finalResponse;
log.info(`========== PIPELINE COMPLETE ==========`);
return currentResponse;
} catch (error: any) {
log.error(`Error in chat pipeline: ${error.message}`);
log.info(`========== PIPELINE ERROR ==========`);
log.error(`Error in chat pipeline: ${error.message || String(error)}`);
throw error;
}
}
/**
* Helper method to get an LLM service for query processing
*/
private async getLLMService(): Promise<LLMServiceInterface | null> {
try {
const aiServiceManager = await import('../ai_service_manager.js').then(module => module.default);
return aiServiceManager.getService();
} catch (error: any) {
log.error(`Error getting LLM service: ${error.message || String(error)}`);
return null;
}
}
/**
* Process a stream chunk through the response processing stage
*/
@ -428,4 +574,52 @@ export class ChatPipeline {
};
});
}
/**
* Find tool name from tool call ID by looking at previous assistant messages
*/
private getToolNameFromToolCallId(messages: Message[], toolCallId: string): string {
if (!toolCallId) return 'unknown';
// Look for assistant messages with tool_calls
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === 'assistant' && message.tool_calls) {
// Find the tool call with the matching ID
const toolCall = message.tool_calls.find(tc => tc.id === toolCallId);
if (toolCall && toolCall.function && toolCall.function.name) {
return toolCall.function.name;
}
}
}
return 'unknown';
}
/**
* Validate tool messages to ensure they're properly formatted
*/
private validateToolMessages(messages: Message[]): void {
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
// Ensure tool messages have required fields
if (message.role === 'tool') {
if (!message.tool_call_id) {
log.info(`Tool message missing tool_call_id, adding placeholder`);
message.tool_call_id = `tool_${i}`;
}
// Content should be a string
if (typeof message.content !== 'string') {
log.info(`Tool message content is not a string, converting`);
try {
message.content = JSON.stringify(message.content);
} catch (e) {
message.content = String(message.content);
}
}
}
}
}
}

View File

@ -22,25 +22,29 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
*/
protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
const { response, messages, options } = input;
log.info(`========== TOOL CALLING STAGE ENTRY ==========`);
log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`);
// Check if the response has tool calls
if (!response.tool_calls || response.tool_calls.length === 0) {
// No tool calls, return original response and messages
log.info(`No tool calls detected in response from provider: ${response.provider}`);
log.info(`===== EXITING TOOL CALLING STAGE: No tool_calls =====`);
return { response, needsFollowUp: false, messages };
}
log.info(`LLM requested ${response.tool_calls.length} tool calls from provider: ${response.provider}`);
// Log response details for debugging
if (response.text) {
log.info(`Response text: "${response.text.substring(0, 200)}${response.text.length > 200 ? '...' : ''}"`);
}
// Check if the registry has any tools
const availableTools = toolRegistry.getAllTools();
log.info(`Available tools in registry: ${availableTools.length}`);
if (availableTools.length === 0) {
log.error(`No tools available in registry, cannot execute tool calls`);
// Try to initialize tools as a recovery step
@ -53,10 +57,10 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
log.error(`Failed to initialize tools in recovery step: ${error.message}`);
}
}
// Create a copy of messages to add the assistant message with tool calls
const updatedMessages = [...messages];
// Add the assistant message with the tool calls
updatedMessages.push({
role: 'assistant',
@ -65,38 +69,44 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
});
// Execute each tool call and add results to messages
const toolResults = await Promise.all(response.tool_calls.map(async (toolCall) => {
log.info(`========== STARTING TOOL EXECUTION ==========`);
const toolResults = await Promise.all(response.tool_calls.map(async (toolCall, index) => {
try {
log.info(`Tool call received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
log.info(`Tool call ${index + 1} received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
// Log parameters
const argsStr = typeof toolCall.function.arguments === 'string'
? toolCall.function.arguments
const argsStr = typeof toolCall.function.arguments === 'string'
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments);
log.info(`Tool parameters: ${argsStr}`);
// Get the tool from registry
const tool = toolRegistry.getTool(toolCall.function.name);
if (!tool) {
log.error(`Tool not found in registry: ${toolCall.function.name}`);
log.info(`Available tools: ${availableTools.map(t => t.definition.function.name).join(', ')}`);
throw new Error(`Tool not found: ${toolCall.function.name}`);
}
log.info(`Tool found in registry: ${toolCall.function.name}`);
// Parse arguments (handle both string and object formats)
let args;
// At this stage, arguments should already be processed by the provider-specific service
// But we still need to handle different formats just in case
if (typeof toolCall.function.arguments === 'string') {
log.info(`Received string arguments in tool calling stage: ${toolCall.function.arguments.substring(0, 50)}...`);
try {
// Try to parse as JSON first
args = JSON.parse(toolCall.function.arguments);
log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`);
} catch (e) {
} catch (e: unknown) {
// If it's not valid JSON, try to check if it's a stringified object with quotes
log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${e.message}`);
const errorMessage = e instanceof Error ? e.message : String(e);
log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${errorMessage}`);
// Sometimes LLMs return stringified JSON with escaped quotes or incorrect quotes
// Try to clean it up
try {
@ -105,13 +115,14 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
.replace(/\\"/g, '"') // Replace escaped quotes
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
log.info(`Cleaned argument string: ${cleaned}`);
args = JSON.parse(cleaned);
log.info(`Successfully parsed cleaned arguments: ${Object.keys(args).join(', ')}`);
} catch (cleanError) {
} catch (cleanError: unknown) {
// If all parsing fails, treat it as a text argument
log.info(`Failed to parse cleaned arguments: ${cleanError.message}`);
const cleanErrorMessage = cleanError instanceof Error ? cleanError.message : String(cleanError);
log.info(`Failed to parse cleaned arguments: ${cleanErrorMessage}`);
args = { text: toolCall.function.arguments };
log.info(`Using text argument: ${args.text.substring(0, 50)}...`);
}
@ -121,12 +132,12 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
args = toolCall.function.arguments;
log.info(`Using object arguments with keys: ${Object.keys(args).join(', ')}`);
}
// Execute the tool
log.info(`================ EXECUTING TOOL: ${toolCall.function.name} ================`);
log.info(`Tool parameters: ${Object.keys(args).join(', ')}`);
log.info(`Parameters values: ${Object.entries(args).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`);
const executionStart = Date.now();
let result;
try {
@ -139,13 +150,14 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${execError.message} ================`);
throw execError;
}
// Log execution result
const resultSummary = typeof result === 'string'
? `${result.substring(0, 100)}...`
const resultSummary = typeof result === 'string'
? `${result.substring(0, 100)}...`
: `Object with keys: ${Object.keys(result).join(', ')}`;
const executionTime = Date.now() - executionStart;
log.info(`Tool execution completed in ${executionTime}ms - Result: ${resultSummary}`);
// Return result with tool call ID
return {
toolCallId: toolCall.id,
@ -154,7 +166,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
};
} catch (error: any) {
log.error(`Error executing tool ${toolCall.function.name}: ${error.message || String(error)}`);
// Return error message as result
return {
toolCallId: toolCall.id,
@ -163,12 +175,12 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
};
}
}));
// Add tool results as messages
toolResults.forEach(result => {
// Format the result content based on type
let content: string;
if (typeof result.result === 'string') {
content = result.result;
log.info(`Tool returned string result (${content.length} chars)`);
@ -182,9 +194,9 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
log.info(`Failed to stringify object result: ${error}`);
}
}
log.info(`Adding tool result message - Tool: ${result.name}, ID: ${result.toolCallId || 'unknown'}, Length: ${content.length}`);
// Create a properly formatted tool response message
updatedMessages.push({
role: 'tool',
@ -192,21 +204,21 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
name: result.name,
tool_call_id: result.toolCallId
});
// Log a sample of the content for debugging
const contentPreview = content.substring(0, 100) + (content.length > 100 ? '...' : '');
log.info(`Tool result preview: ${contentPreview}`);
});
log.info(`Added ${toolResults.length} tool results to conversation`);
// If we have tool results, we need a follow-up call to the LLM
const needsFollowUp = toolResults.length > 0;
if (needsFollowUp) {
log.info(`Tool execution complete, LLM follow-up required with ${updatedMessages.length} messages`);
}
return {
response,
needsFollowUp,

View File

@ -107,14 +107,14 @@ export class OllamaService extends BaseAIService {
const tools = toolRegistry.getAllToolDefinitions();
requestBody.tools = tools;
log.info(`Adding ${tools.length} tools to request`);
// If no tools found, reinitialize
if (tools.length === 0) {
log.info('No tools found in registry, re-initializing...');
try {
const toolInitializer = await import('../tools/tool_initializer.js');
await toolInitializer.default.initializeTools();
// Try again
requestBody.tools = toolRegistry.getAllToolDefinitions();
log.info(`After re-initialization: ${requestBody.tools.length} tools available`);
@ -139,43 +139,43 @@ export class OllamaService extends BaseAIService {
log.info(`========== OLLAMA API REQUEST ==========`);
log.info(`Model: ${requestBody.model}, Messages: ${requestBody.messages.length}, Tools: ${requestBody.tools ? requestBody.tools.length : 0}`);
log.info(`Temperature: ${temperature}, Stream: ${requestBody.stream}, JSON response expected: ${expectsJsonResponse}`);
// Check message structure and log detailed information about each message
requestBody.messages.forEach((msg: any, index: number) => {
const keys = Object.keys(msg);
log.info(`Message ${index}, Role: ${msg.role}, Keys: ${keys.join(', ')}`);
// Log message content preview
if (msg.content && typeof msg.content === 'string') {
const contentPreview = msg.content.length > 200
? `${msg.content.substring(0, 200)}...`
const contentPreview = msg.content.length > 200
? `${msg.content.substring(0, 200)}...`
: msg.content;
log.info(`Message ${index} content: ${contentPreview}`);
}
// Log tool-related details
if (keys.includes('tool_calls')) {
log.info(`Message ${index} has ${msg.tool_calls.length} tool calls:`);
msg.tool_calls.forEach((call: any, callIdx: number) => {
log.info(` Tool call ${callIdx}: ${call.function?.name || 'unknown'}, ID: ${call.id || 'unspecified'}`);
if (call.function?.arguments) {
const argsPreview = typeof call.function.arguments === 'string'
? call.function.arguments.substring(0, 100)
const argsPreview = typeof call.function.arguments === 'string'
? call.function.arguments.substring(0, 100)
: JSON.stringify(call.function.arguments).substring(0, 100);
log.info(` Arguments: ${argsPreview}...`);
}
});
}
if (keys.includes('tool_call_id')) {
log.info(`Message ${index} is a tool response for tool call ID: ${msg.tool_call_id}`);
}
if (keys.includes('name') && msg.role === 'tool') {
log.info(`Message ${index} is from tool: ${msg.name}`);
}
});
// Log tool definitions
if (requestBody.tools && requestBody.tools.length > 0) {
log.info(`Sending ${requestBody.tools.length} tool definitions:`);
@ -185,19 +185,19 @@ export class OllamaService extends BaseAIService {
log.info(` Description: ${tool.function.description.substring(0, 100)}...`);
}
if (tool.function?.parameters) {
const paramNames = tool.function.parameters.properties
? Object.keys(tool.function.parameters.properties)
const paramNames = tool.function.parameters.properties
? Object.keys(tool.function.parameters.properties)
: [];
log.info(` Parameters: ${paramNames.join(', ')}`);
}
});
}
// Log full request body (this will create large logs but is helpful for debugging)
const requestStr = JSON.stringify(requestBody);
log.info(`Full Ollama request (truncated): ${requestStr.substring(0, 1000)}...`);
log.info(`========== END OLLAMA REQUEST ==========`);
// Make API request
const response = await fetch(`${apiBase}/api/chat`, {
method: 'POST',
@ -214,20 +214,20 @@ export class OllamaService extends BaseAIService {
}
const data: OllamaResponse = await response.json();
// Log response details
log.info(`========== OLLAMA API RESPONSE ==========`);
log.info(`Model: ${data.model}, Content length: ${data.message.content.length} chars`);
log.info(`Tokens: ${data.prompt_eval_count} prompt, ${data.eval_count} completion, ${data.prompt_eval_count + data.eval_count} total`);
log.info(`Duration: ${data.total_duration}ns total, ${data.prompt_eval_duration}ns prompt, ${data.eval_duration}ns completion`);
log.info(`Done: ${data.done}, Reason: ${data.done_reason || 'not specified'}`);
// Log content preview
const contentPreview = data.message.content.length > 300
? `${data.message.content.substring(0, 300)}...`
const contentPreview = data.message.content.length > 300
? `${data.message.content.substring(0, 300)}...`
: data.message.content;
log.info(`Response content: ${contentPreview}`);
// Handle the response and extract tool calls if present
const chatResponse: ChatResponse = {
text: data.message.content,
@ -242,45 +242,47 @@ export class OllamaService extends BaseAIService {
// Add tool calls if present
if (data.message.tool_calls && data.message.tool_calls.length > 0) {
log.info(`========== OLLAMA TOOL CALLS DETECTED ==========`);
log.info(`Ollama response includes ${data.message.tool_calls.length} tool calls`);
// Log detailed information about each tool call
const transformedToolCalls: ToolCall[] = [];
// Log detailed information about the tool calls in the response
log.info(`========== OLLAMA TOOL CALLS IN RESPONSE ==========`);
data.message.tool_calls.forEach((toolCall, index) => {
log.info(`Tool call ${index + 1}:`);
log.info(` Name: ${toolCall.function?.name || 'unknown'}`);
log.info(` ID: ${toolCall.id || `auto-${index + 1}`}`);
// Generate a unique ID if none is provided
const id = toolCall.id || `tool-call-${Date.now()}-${index}`;
// Handle arguments based on their type
let processedArguments: Record<string, any> | string;
if (typeof toolCall.function.arguments === 'string') {
// Log raw string arguments in full for debugging
log.info(` Raw string arguments: ${toolCall.function.arguments}`);
// Try to parse JSON string arguments
try {
processedArguments = JSON.parse(toolCall.function.arguments);
log.info(` Successfully parsed arguments to object with keys: ${Object.keys(processedArguments).join(', ')}`);
log.info(` Parsed argument values:`);
Object.entries(processedArguments).forEach(([key, value]) => {
const valuePreview = typeof value === 'string'
const valuePreview = typeof value === 'string'
? (value.length > 100 ? `${value.substring(0, 100)}...` : value)
: JSON.stringify(value);
log.info(` ${key}: ${valuePreview}`);
});
} catch (e) {
} catch (e: unknown) {
// If parsing fails, keep as string and log the error
processedArguments = toolCall.function.arguments;
log.info(` Could not parse arguments as JSON: ${e.message}`);
const errorMessage = e instanceof Error ? e.message : String(e);
log.info(` Could not parse arguments as JSON: ${errorMessage}`);
log.info(` Keeping as string: ${processedArguments.substring(0, 200)}${processedArguments.length > 200 ? '...' : ''}`);
// Try to clean and parse again with more aggressive methods
try {
const cleaned = toolCall.function.arguments
@ -288,12 +290,13 @@ export class OllamaService extends BaseAIService {
.replace(/\\"/g, '"') // Replace escaped quotes
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
log.info(` Attempting to parse cleaned argument: ${cleaned}`);
const reparseArg = JSON.parse(cleaned);
log.info(` Successfully parsed cleaned argument with keys: ${Object.keys(reparseArg).join(', ')}`);
} catch (cleanErr) {
log.info(` Failed to parse cleaned arguments: ${cleanErr.message}`);
} catch (cleanErr: unknown) {
const cleanErrMessage = cleanErr instanceof Error ? cleanErr.message : String(cleanErr);
log.info(` Failed to parse cleaned arguments: ${cleanErrMessage}`);
}
}
} else {
@ -302,13 +305,13 @@ export class OllamaService extends BaseAIService {
log.info(` Object arguments with keys: ${Object.keys(processedArguments).join(', ')}`);
log.info(` Argument values:`);
Object.entries(processedArguments).forEach(([key, value]) => {
const valuePreview = typeof value === 'string'
const valuePreview = typeof value === 'string'
? (value.length > 100 ? `${value.substring(0, 100)}...` : value)
: JSON.stringify(value);
log.info(` ${key}: ${valuePreview}`);
});
}
// Convert to our standard ToolCall format
transformedToolCalls.push({
id,
@ -319,11 +322,39 @@ export class OllamaService extends BaseAIService {
}
});
});
// Add transformed tool calls to response
chatResponse.tool_calls = transformedToolCalls;
log.info(`Transformed ${transformedToolCalls.length} tool calls for execution`);
log.info(`Tool calls after transformation: ${JSON.stringify(chatResponse.tool_calls)}`);
// CRITICAL: Explicitly mark response for tool execution
log.info(`CRITICAL: Explicitly marking response for tool execution`);
// Ensure tool_calls is properly exposed and formatted
// This is to make sure the pipeline can detect and execute the tools
if (transformedToolCalls.length > 0) {
// Make sure the tool_calls are exposed in the exact format expected by pipeline
chatResponse.tool_calls = transformedToolCalls.map(tc => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}));
// If the content is empty, use a placeholder to avoid issues
if (!chatResponse.text) {
chatResponse.text = "Processing your request...";
}
log.info(`Final tool_calls format for pipeline: ${JSON.stringify(chatResponse.tool_calls)}`);
}
log.info(`========== END OLLAMA TOOL CALLS ==========`);
} else {
log.info(`========== NO OLLAMA TOOL CALLS DETECTED ==========`);
log.info(`Checking raw message response format: ${JSON.stringify(data.message)}`);
}
log.info(`========== END OLLAMA RESPONSE ==========`);

View File

@ -4,7 +4,7 @@ import type { Message, ChatCompletionOptions } from "./ai_interface.js";
import contextService from "./context_service.js";
import { LLM_CONSTANTS } from './constants/provider_constants.js';
import { ERROR_PROMPTS } from './constants/llm_prompt_constants.js';
import * as aiServiceManagerModule from "./ai_service_manager.js";
import aiServiceManagerImport from "./ai_service_manager.js";
import becca from "../../becca/becca.js";
import vectorStore from "./embeddings/index.js";
import providerManager from "./providers/providers.js";
@ -94,7 +94,7 @@ class RestChatService {
// Try to access the manager - will create instance only if needed
try {
const aiManager = aiServiceManagerModule.default;
const aiManager = aiServiceManagerImport.getInstance();
if (!aiManager) {
log.info("AI check failed: AI manager module is not available");
@ -315,7 +315,7 @@ class RestChatService {
log.info("AI services are not available - checking for specific issues");
try {
const aiManager = aiServiceManagerModule.default;
const aiManager = aiServiceManagerImport.getInstance();
if (!aiManager) {
log.error("AI service manager is not initialized");
@ -341,7 +341,7 @@ class RestChatService {
}
// Get the AI service manager
const aiServiceManager = aiServiceManagerModule.default.getInstance();
const aiServiceManager = aiServiceManagerImport.getInstance();
// Get the default service - just use the first available one
const availableProviders = aiServiceManager.getAvailableProviders();
@ -468,6 +468,9 @@ class RestChatService {
// Use the Trilium-specific approach
const contextNoteId = session.noteContext || null;
// Ensure tools are initialized to prevent tool execution issues
await this.ensureToolsInitialized();
// Log that we're calling contextService with the parameters
log.info(`Using enhanced context with: noteId=${contextNoteId}, showThinking=${showThinking}`);
@ -506,23 +509,67 @@ class RestChatService {
temperature: session.metadata.temperature || 0.7,
maxTokens: session.metadata.maxTokens,
model: session.metadata.model,
stream: isStreamingRequest ? true : undefined
stream: isStreamingRequest ? true : undefined,
enableTools: true // Explicitly enable tools
};
// Process based on whether this is a streaming request
// Add a note indicating we're explicitly enabling tools
log.info(`Advanced context flow: explicitly enabling tools in chat options`);
// Process streaming responses differently
if (isStreamingRequest) {
// Handle streaming using the existing method
await this.handleStreamingResponse(res, aiMessages, chatOptions, service, session);
} else {
// Non-streaming approach for POST requests
// For non-streaming requests, generate a completion synchronously
const response = await service.generateChatCompletion(aiMessages, chatOptions);
const aiResponse = response.text; // Extract the text from the response
// Store the assistant's response in the session
session.messages.push({
role: 'assistant',
content: aiResponse,
timestamp: new Date()
});
// Check if the response contains tool calls
if (response.tool_calls && response.tool_calls.length > 0) {
log.info(`Advanced context non-streaming: detected ${response.tool_calls.length} tool calls in response`);
log.info(`Tool calls details: ${JSON.stringify(response.tool_calls)}`);
try {
// Execute the tools
const toolResults = await this.executeToolCalls(response);
log.info(`Successfully executed ${toolResults.length} tool calls in advanced context flow`);
// Build updated messages with tool results
const toolMessages = [...aiMessages, {
role: 'assistant',
content: response.text || '',
tool_calls: response.tool_calls
}, ...toolResults];
// Make a follow-up request with the tool results
log.info(`Making follow-up request with ${toolResults.length} tool results`);
const followUpOptions = {...chatOptions, enableTools: false}; // Disable tools for follow-up
const followUpResponse = await service.generateChatCompletion(toolMessages, followUpOptions);
// Update the session with the final response
session.messages.push({
role: 'assistant',
content: followUpResponse.text || '',
timestamp: new Date()
});
} catch (toolError: any) {
log.error(`Error executing tools in advanced context: ${toolError.message}`);
// Add error response to session
session.messages.push({
role: 'assistant',
content: `Error executing tools: ${toolError.message}`,
timestamp: new Date()
});
}
} else {
// No tool calls, just add the response to the session
session.messages.push({
role: 'assistant',
content: response.text || '',
timestamp: new Date()
});
}
}
return sourceNotes;
@ -608,50 +655,57 @@ class RestChatService {
try {
// Use the correct method name: generateChatCompletion
const response = await service.generateChatCompletion(aiMessages, chatOptions);
// Check for tool calls in the response
if (response.tool_calls && response.tool_calls.length > 0) {
log.info(`========== STREAMING TOOL CALLS DETECTED ==========`);
log.info(`Response contains ${response.tool_calls.length} tool calls, executing them...`);
log.info(`CRITICAL CHECK: Tool execution is supposed to happen in the pipeline, not directly here.`);
log.info(`If tools are being executed here instead of in the pipeline, this may be a flow issue.`);
log.info(`Response came from provider: ${response.provider || 'unknown'}, model: ${response.model || 'unknown'}`);
try {
log.info(`========== STREAMING TOOL EXECUTION PATH ==========`);
log.info(`About to execute tools in streaming path (this is separate from pipeline tool execution)`);
// Execute the tools
const toolResults = await this.executeToolCalls(response);
log.info(`Successfully executed ${toolResults.length} tool calls in streaming path`);
// Make a follow-up request with the tool results
const toolMessages = [...aiMessages, {
role: 'assistant',
content: response.text || '',
tool_calls: response.tool_calls
}, ...toolResults];
log.info(`Making follow-up request with ${toolResults.length} tool results`);
// Send partial response to let the client know tools are being processed
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ content: "Processing tools... " })}\n\n`);
}
// Use non-streaming for the follow-up to get a complete response
const followUpOptions = {...chatOptions, stream: false, enableTools: false}; // Prevent infinite loops
const followUpResponse = await service.generateChatCompletion(toolMessages, followUpOptions);
messageContent = followUpResponse.text || "";
// Send the complete response as a single chunk
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ content: messageContent })}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
}
// Store the full response for the session
session.messages.push({
role: 'assistant',
content: messageContent,
timestamp: new Date()
});
return; // Skip the rest of the processing
} catch (toolError) {
log.error(`Error executing tools: ${toolError}`);
@ -716,48 +770,55 @@ class RestChatService {
}
}
}
/**
* Execute tool calls from the LLM response
* @param response The LLM response containing tool calls
*/
private async executeToolCalls(response: any): Promise<Message[]> {
log.info(`========== REST SERVICE TOOL EXECUTION FLOW ==========`);
log.info(`Entered executeToolCalls method in REST chat service`);
if (!response.tool_calls || response.tool_calls.length === 0) {
log.info(`No tool calls to execute, returning early`);
return [];
}
log.info(`Executing ${response.tool_calls.length} tool calls from REST chat service`);
// Import tool registry directly to avoid circular dependencies
const toolRegistry = (await import('./tools/tool_registry.js')).default;
// Check if tools are available
const availableTools = toolRegistry.getAllTools();
log.info(`Available tools in registry: ${availableTools.length}`);
if (availableTools.length === 0) {
log.error('No tools available in registry for execution');
// Try to initialize tools
try {
const toolInitializer = await import('./tools/tool_initializer.js');
await toolInitializer.default.initializeTools();
log.info(`Initialized ${toolRegistry.getAllTools().length} tools`);
} catch (error) {
log.error(`Failed to initialize tools: ${error}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Failed to initialize tools: ${errorMessage}`);
throw new Error('Tool execution failed: No tools available');
}
}
// Execute each tool call and collect results
const toolResults = await Promise.all(response.tool_calls.map(async (toolCall: any) => {
try {
log.info(`Executing tool: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
// Get the tool from registry
const tool = toolRegistry.getTool(toolCall.function.name);
if (!tool) {
throw new Error(`Tool not found: ${toolCall.function.name}`);
}
// Parse arguments
let args;
if (typeof toolCall.function.arguments === 'string') {
@ -765,7 +826,7 @@ class RestChatService {
args = JSON.parse(toolCall.function.arguments);
} catch (e) {
log.error(`Failed to parse tool arguments: ${e.message}`);
// Try cleanup and retry
try {
const cleaned = toolCall.function.arguments
@ -773,7 +834,7 @@ class RestChatService {
.replace(/\\"/g, '"') // Replace escaped quotes
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
args = JSON.parse(cleaned);
} catch (cleanErr) {
// If all parsing fails, use as-is
@ -783,23 +844,23 @@ class RestChatService {
} else {
args = toolCall.function.arguments;
}
// Log what we're about to execute
log.info(`Executing tool with arguments: ${JSON.stringify(args)}`);
// Execute the tool and get result
const startTime = Date.now();
const result = await tool.execute(args);
const executionTime = Date.now() - startTime;
log.info(`Tool execution completed in ${executionTime}ms`);
// Log the result
const resultPreview = typeof result === 'string'
const resultPreview = typeof result === 'string'
? result.substring(0, 100) + (result.length > 100 ? '...' : '')
: JSON.stringify(result).substring(0, 100) + '...';
log.info(`Tool result: ${resultPreview}`);
// Format result as a proper message
return {
role: 'tool',
@ -809,7 +870,7 @@ class RestChatService {
};
} catch (error: any) {
log.error(`Error executing tool ${toolCall.function.name}: ${error.message}`);
// Return error as tool result
return {
role: 'tool',
@ -819,7 +880,7 @@ class RestChatService {
};
}
}));
log.info(`Completed execution of ${toolResults.length} tools`);
return toolResults;
}
@ -1042,6 +1103,33 @@ class RestChatService {
throw new Error(`Failed to delete session: ${error.message || 'Unknown error'}`);
}
}
/**
* Ensure that LLM tools are properly initialized
* This helps prevent issues with tool execution
*/
private async ensureToolsInitialized(): Promise<void> {
try {
log.info("Initializing LLM agent tools...");
// Initialize LLM tools without depending on aiServiceManager
const toolInitializer = await import('./tools/tool_initializer.js');
await toolInitializer.default.initializeTools();
// Get the tool registry to check if tools were initialized
const toolRegistry = (await import('./tools/tool_registry.js')).default;
const tools = toolRegistry.getAllTools();
log.info(`LLM tools initialized successfully: ${tools.length} tools available`);
// Log available tools
if (tools.length > 0) {
log.info(`Available tools: ${tools.map(t => t.definition.function.name).join(', ')}`);
}
} catch (error: any) {
log.error(`Error initializing LLM tools: ${error.message}`);
// Don't throw, just log the error to prevent breaking the pipeline
}
}
}
// Create singleton instance

View File

@ -0,0 +1,226 @@
/**
* Attribute Manager Tool
*
* This tool allows the LLM to add, remove, or modify note attributes in Trilium.
*/
import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import becca from '../../../becca/becca.js';
import attributes from '../../attributes.js';
/**
* Definition of the attribute manager tool
*/
export const attributeManagerToolDefinition: Tool = {
type: 'function',
function: {
name: 'manage_attributes',
description: 'Add, remove, or modify attributes (labels/relations) on a note',
parameters: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'ID of the note to manage attributes for'
},
action: {
type: 'string',
description: 'Action to perform on the attribute',
enum: ['add', 'remove', 'update', 'list']
},
attributeName: {
type: 'string',
description: 'Name of the attribute (e.g., "#tag" for a label, or "relation" for a relation)'
},
attributeValue: {
type: 'string',
description: 'Value of the attribute (for add/update actions). Not needed for label-type attributes.'
}
},
required: ['noteId', 'action']
}
}
};
/**
* Attribute manager tool implementation
*/
export class AttributeManagerTool implements ToolHandler {
public definition: Tool = attributeManagerToolDefinition;
/**
* Execute the attribute manager tool
*/
public async execute(args: { noteId: string, action: string, attributeName?: string, attributeValue?: string }): Promise<string | object> {
try {
const { noteId, action, attributeName, attributeValue } = args;
log.info(`Executing manage_attributes tool - NoteID: "${noteId}", Action: ${action}, AttributeName: ${attributeName || 'not specified'}`);
// Get the note from becca
const note = becca.notes[noteId];
if (!note) {
log.info(`Note with ID ${noteId} not found - returning error`);
return `Error: Note with ID ${noteId} not found`;
}
log.info(`Found note: "${note.title}" (Type: ${note.type})`);
// List all existing attributes
if (action === 'list') {
const noteAttributes = note.getOwnedAttributes();
log.info(`Listing ${noteAttributes.length} attributes for note "${note.title}"`);
const formattedAttributes = noteAttributes.map(attr => ({
name: attr.name,
value: attr.value,
type: attr.type
}));
return {
success: true,
noteId: note.noteId,
title: note.title,
attributeCount: noteAttributes.length,
attributes: formattedAttributes
};
}
// For other actions, attribute name is required
if (!attributeName) {
return 'Error: attributeName is required for add, remove, and update actions';
}
// Perform the requested action
if (action === 'add') {
// Add a new attribute
try {
const startTime = Date.now();
// For label-type attributes (starting with #), no value is needed
const isLabel = attributeName.startsWith('#');
const value = isLabel ? '' : (attributeValue || '');
// Check if attribute already exists
const existingAttrs = note.getOwnedAttributes()
.filter(attr => attr.name === attributeName && attr.value === value);
if (existingAttrs.length > 0) {
log.info(`Attribute ${attributeName}=${value} already exists on note "${note.title}"`);
return {
success: false,
message: `Attribute ${attributeName}=${value || ''} already exists on note "${note.title}"`
};
}
// Create the attribute
await attributes.createAttribute(noteId, attributeName, value);
const duration = Date.now() - startTime;
log.info(`Added attribute ${attributeName}=${value || ''} in ${duration}ms`);
return {
success: true,
noteId: note.noteId,
title: note.title,
action: 'add',
attributeName: attributeName,
attributeValue: value,
message: `Added attribute ${attributeName}=${value || ''} to note "${note.title}"`
};
} catch (error: any) {
log.error(`Error adding attribute: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
} else if (action === 'remove') {
// Remove an attribute
try {
const startTime = Date.now();
// Find the attribute to remove
const attributesToRemove = note.getOwnedAttributes()
.filter(attr => attr.name === attributeName &&
(attributeValue === undefined || attr.value === attributeValue));
if (attributesToRemove.length === 0) {
log.info(`Attribute ${attributeName} not found on note "${note.title}"`);
return {
success: false,
message: `Attribute ${attributeName} not found on note "${note.title}"`
};
}
// Remove all matching attributes
for (const attr of attributesToRemove) {
await attributes.deleteAttribute(attr.attributeId);
}
const duration = Date.now() - startTime;
log.info(`Removed ${attributesToRemove.length} attribute(s) in ${duration}ms`);
return {
success: true,
noteId: note.noteId,
title: note.title,
action: 'remove',
attributeName: attributeName,
attributesRemoved: attributesToRemove.length,
message: `Removed ${attributesToRemove.length} attribute(s) from note "${note.title}"`
};
} catch (error: any) {
log.error(`Error removing attribute: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
} else if (action === 'update') {
// Update an attribute
try {
const startTime = Date.now();
if (attributeValue === undefined) {
return 'Error: attributeValue is required for update action';
}
// Find the attribute to update
const attributesToUpdate = note.getOwnedAttributes()
.filter(attr => attr.name === attributeName);
if (attributesToUpdate.length === 0) {
log.info(`Attribute ${attributeName} not found on note "${note.title}"`);
return {
success: false,
message: `Attribute ${attributeName} not found on note "${note.title}"`
};
}
// Update all matching attributes
for (const attr of attributesToUpdate) {
await attributes.updateAttributeValue(attr.attributeId, attributeValue);
}
const duration = Date.now() - startTime;
log.info(`Updated ${attributesToUpdate.length} attribute(s) in ${duration}ms`);
return {
success: true,
noteId: note.noteId,
title: note.title,
action: 'update',
attributeName: attributeName,
attributeValue: attributeValue,
attributesUpdated: attributesToUpdate.length,
message: `Updated ${attributesToUpdate.length} attribute(s) on note "${note.title}"`
};
} catch (error: any) {
log.error(`Error updating attribute: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
} else {
return `Error: Unsupported action "${action}". Supported actions are: add, remove, update, list`;
}
} catch (error: any) {
log.error(`Error executing manage_attributes tool: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
}
}

View File

@ -0,0 +1,481 @@
/**
* 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: 'Find date-related notes or create date-based entries',
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 noteId = await notes.createNewNote({
parentNoteId: parent.noteId,
title: title,
content: content,
type: 'text',
mime: 'text/html'
});
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)
};
}
}

View File

@ -0,0 +1,544 @@
/**
* Content Extraction Tool
*
* This tool allows the LLM to extract structured information from notes.
*/
import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import becca from '../../../becca/becca.js';
/**
* Definition of the content extraction tool
*/
export const contentExtractionToolDefinition: Tool = {
type: 'function',
function: {
name: 'extract_content',
description: 'Extract structured information from a note\'s content, such as lists, tables, or specific sections',
parameters: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'ID of the note to extract content from'
},
extractionType: {
type: 'string',
description: 'Type of content to extract',
enum: ['lists', 'tables', 'headings', 'codeBlocks', 'all']
},
format: {
type: 'string',
description: 'Format to return the extracted content in',
enum: ['json', 'markdown', 'text']
},
query: {
type: 'string',
description: 'Optional search query to filter extracted content (e.g., "tasks related to finance")'
}
},
required: ['noteId', 'extractionType']
}
}
};
/**
* Content extraction tool implementation
*/
export class ContentExtractionTool implements ToolHandler {
public definition: Tool = contentExtractionToolDefinition;
/**
* Execute the content extraction tool
*/
public async execute(args: {
noteId: string,
extractionType: 'lists' | 'tables' | 'headings' | 'codeBlocks' | 'all',
format?: 'json' | 'markdown' | 'text',
query?: string
}): Promise<string | object> {
try {
const { noteId, extractionType, format = 'json', query } = args;
log.info(`Executing extract_content tool - NoteID: "${noteId}", Type: ${extractionType}, Format: ${format}`);
// Get the note from becca
const note = becca.notes[noteId];
if (!note) {
log.info(`Note with ID ${noteId} not found - returning error`);
return `Error: Note with ID ${noteId} not found`;
}
log.info(`Found note: "${note.title}" (Type: ${note.type})`);
// Get the note content
const content = await note.getContent();
if (!content) {
return {
success: false,
message: 'Note content is empty'
};
}
log.info(`Retrieved note content, length: ${content.length} chars`);
// Extract the requested content
const extractedContent: any = {};
if (extractionType === 'lists' || extractionType === 'all') {
extractedContent.lists = this.extractLists(content);
log.info(`Extracted ${extractedContent.lists.length} lists`);
}
if (extractionType === 'tables' || extractionType === 'all') {
extractedContent.tables = this.extractTables(content);
log.info(`Extracted ${extractedContent.tables.length} tables`);
}
if (extractionType === 'headings' || extractionType === 'all') {
extractedContent.headings = this.extractHeadings(content);
log.info(`Extracted ${extractedContent.headings.length} headings`);
}
if (extractionType === 'codeBlocks' || extractionType === 'all') {
extractedContent.codeBlocks = this.extractCodeBlocks(content);
log.info(`Extracted ${extractedContent.codeBlocks.length} code blocks`);
}
// Filter by query if provided
if (query) {
log.info(`Filtering extracted content with query: "${query}"`);
this.filterContentByQuery(extractedContent, query);
}
// Format the response based on requested format
if (format === 'markdown') {
return this.formatAsMarkdown(extractedContent, extractionType);
} else if (format === 'text') {
return this.formatAsText(extractedContent, extractionType);
} else {
// Default to JSON format
return {
success: true,
noteId: note.noteId,
title: note.title,
extractionType,
content: extractedContent
};
}
} catch (error: any) {
log.error(`Error executing extract_content tool: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
}
/**
* Extract lists from HTML content
*/
private extractLists(content: string): Array<{ type: string, items: string[] }> {
const lists = [];
// Extract unordered lists
const ulRegex = /<ul[^>]*>([\s\S]*?)<\/ul>/gi;
let ulMatch;
while ((ulMatch = ulRegex.exec(content)) !== null) {
const listContent = ulMatch[1];
const items = this.extractListItems(listContent);
if (items.length > 0) {
lists.push({
type: 'unordered',
items
});
}
}
// Extract ordered lists
const olRegex = /<ol[^>]*>([\s\S]*?)<\/ol>/gi;
let olMatch;
while ((olMatch = olRegex.exec(content)) !== null) {
const listContent = olMatch[1];
const items = this.extractListItems(listContent);
if (items.length > 0) {
lists.push({
type: 'ordered',
items
});
}
}
return lists;
}
/**
* Extract list items from list content
*/
private extractListItems(listContent: string): string[] {
const items = [];
const itemRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
let itemMatch;
while ((itemMatch = itemRegex.exec(listContent)) !== null) {
const itemText = this.stripHtml(itemMatch[1]).trim();
if (itemText) {
items.push(itemText);
}
}
return items;
}
/**
* Extract tables from HTML content
*/
private extractTables(content: string): Array<{ headers: string[], rows: string[][] }> {
const tables = [];
const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
let tableMatch;
while ((tableMatch = tableRegex.exec(content)) !== null) {
const tableContent = tableMatch[1];
const headers = [];
const rows = [];
// Extract table headers
const headerRegex = /<th[^>]*>([\s\S]*?)<\/th>/gi;
let headerMatch;
while ((headerMatch = headerRegex.exec(tableContent)) !== null) {
headers.push(this.stripHtml(headerMatch[1]).trim());
}
// Extract table rows
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let rowMatch;
while ((rowMatch = rowRegex.exec(tableContent)) !== null) {
const rowContent = rowMatch[1];
const cells = [];
const cellRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
let cellMatch;
while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
cells.push(this.stripHtml(cellMatch[1]).trim());
}
if (cells.length > 0) {
rows.push(cells);
}
}
if (headers.length > 0 || rows.length > 0) {
tables.push({
headers,
rows
});
}
}
return tables;
}
/**
* Extract headings from HTML content
*/
private extractHeadings(content: string): Array<{ level: number, text: string }> {
const headings = [];
for (let i = 1; i <= 6; i++) {
const headingRegex = new RegExp(`<h${i}[^>]*>([\s\S]*?)<\/h${i}>`, 'gi');
let headingMatch;
while ((headingMatch = headingRegex.exec(content)) !== null) {
const headingText = this.stripHtml(headingMatch[1]).trim();
if (headingText) {
headings.push({
level: i,
text: headingText
});
}
}
}
return headings;
}
/**
* Extract code blocks from HTML content
*/
private extractCodeBlocks(content: string): Array<{ language?: string, code: string }> {
const codeBlocks = [];
// Look for <pre> and <code> blocks
const preRegex = /<pre[^>]*>([\s\S]*?)<\/pre>/gi;
let preMatch;
while ((preMatch = preRegex.exec(content)) !== null) {
const preContent = preMatch[1];
// Check if there's a nested <code> tag
const codeMatch = /<code[^>]*>([\s\S]*?)<\/code>/i.exec(preContent);
if (codeMatch) {
// Extract language if it's in the class attribute
const classMatch = /class="[^"]*language-([^"\s]+)[^"]*"/i.exec(preMatch[0]);
codeBlocks.push({
language: classMatch ? classMatch[1] : undefined,
code: this.decodeHtmlEntities(codeMatch[1]).trim()
});
} else {
// Just a <pre> without <code>
codeBlocks.push({
code: this.decodeHtmlEntities(preContent).trim()
});
}
}
// Also look for standalone <code> blocks not inside <pre>
const standaloneCodeRegex = /(?<!<pre[^>]*>[\s\S]*?)<code[^>]*>([\s\S]*?)<\/code>/gi;
let standaloneCodeMatch;
while ((standaloneCodeMatch = standaloneCodeRegex.exec(content)) !== null) {
codeBlocks.push({
code: this.decodeHtmlEntities(standaloneCodeMatch[1]).trim()
});
}
return codeBlocks;
}
/**
* Filter content by query
*/
private filterContentByQuery(content: any, query: string): void {
const lowerQuery = query.toLowerCase();
// Filter lists
if (content.lists) {
content.lists = content.lists.filter(list => {
// Keep the list if any item matches the query
return list.items.some(item => item.toLowerCase().includes(lowerQuery));
});
// Also filter individual items in each list
content.lists.forEach(list => {
list.items = list.items.filter(item => item.toLowerCase().includes(lowerQuery));
});
}
// Filter headings
if (content.headings) {
content.headings = content.headings.filter(heading =>
heading.text.toLowerCase().includes(lowerQuery)
);
}
// Filter tables
if (content.tables) {
content.tables = content.tables.filter(table => {
// Check headers
const headerMatch = table.headers.some(header =>
header.toLowerCase().includes(lowerQuery)
);
// Check cells
const cellMatch = table.rows.some(row =>
row.some(cell => cell.toLowerCase().includes(lowerQuery))
);
return headerMatch || cellMatch;
});
}
// Filter code blocks
if (content.codeBlocks) {
content.codeBlocks = content.codeBlocks.filter(block =>
block.code.toLowerCase().includes(lowerQuery)
);
}
}
/**
* Format extracted content as Markdown
*/
private formatAsMarkdown(content: any, extractionType: string): string {
let markdown = '';
if (extractionType === 'lists' || extractionType === 'all') {
if (content.lists && content.lists.length > 0) {
markdown += '## Lists\n\n';
content.lists.forEach((list: any, index: number) => {
markdown += `### List ${index + 1} (${list.type})\n\n`;
list.items.forEach((item: string) => {
if (list.type === 'unordered') {
markdown += `- ${item}\n`;
} else {
markdown += `1. ${item}\n`;
}
});
markdown += '\n';
});
}
}
if (extractionType === 'headings' || extractionType === 'all') {
if (content.headings && content.headings.length > 0) {
markdown += '## Headings\n\n';
content.headings.forEach((heading: any) => {
markdown += `${'#'.repeat(heading.level)} ${heading.text}\n\n`;
});
}
}
if (extractionType === 'tables' || extractionType === 'all') {
if (content.tables && content.tables.length > 0) {
markdown += '## Tables\n\n';
content.tables.forEach((table: any, index: number) => {
markdown += `### Table ${index + 1}\n\n`;
// Add headers
if (table.headers.length > 0) {
markdown += '| ' + table.headers.join(' | ') + ' |\n';
markdown += '| ' + table.headers.map(() => '---').join(' | ') + ' |\n';
}
// Add rows
table.rows.forEach((row: string[]) => {
markdown += '| ' + row.join(' | ') + ' |\n';
});
markdown += '\n';
});
}
}
if (extractionType === 'codeBlocks' || extractionType === 'all') {
if (content.codeBlocks && content.codeBlocks.length > 0) {
markdown += '## Code Blocks\n\n';
content.codeBlocks.forEach((block: any, index: number) => {
markdown += `### Code Block ${index + 1}\n\n`;
if (block.language) {
markdown += '```' + block.language + '\n';
} else {
markdown += '```\n';
}
markdown += block.code + '\n';
markdown += '```\n\n';
});
}
}
return markdown.trim();
}
/**
* Format extracted content as plain text
*/
private formatAsText(content: any, extractionType: string): string {
let text = '';
if (extractionType === 'lists' || extractionType === 'all') {
if (content.lists && content.lists.length > 0) {
text += 'LISTS:\n\n';
content.lists.forEach((list: any, index: number) => {
text += `List ${index + 1} (${list.type}):\n\n`;
list.items.forEach((item: string, itemIndex: number) => {
if (list.type === 'unordered') {
text += `${item}\n`;
} else {
text += `${itemIndex + 1}. ${item}\n`;
}
});
text += '\n';
});
}
}
if (extractionType === 'headings' || extractionType === 'all') {
if (content.headings && content.headings.length > 0) {
text += 'HEADINGS:\n\n';
content.headings.forEach((heading: any) => {
text += `${heading.text} (Level ${heading.level})\n`;
});
text += '\n';
}
}
if (extractionType === 'tables' || extractionType === 'all') {
if (content.tables && content.tables.length > 0) {
text += 'TABLES:\n\n';
content.tables.forEach((table: any, index: number) => {
text += `Table ${index + 1}:\n\n`;
// Add headers
if (table.headers.length > 0) {
text += table.headers.join(' | ') + '\n';
text += table.headers.map(() => '-----').join(' | ') + '\n';
}
// Add rows
table.rows.forEach((row: string[]) => {
text += row.join(' | ') + '\n';
});
text += '\n';
});
}
}
if (extractionType === 'codeBlocks' || extractionType === 'all') {
if (content.codeBlocks && content.codeBlocks.length > 0) {
text += 'CODE BLOCKS:\n\n';
content.codeBlocks.forEach((block: any, index: number) => {
text += `Code Block ${index + 1}`;
if (block.language) {
text += ` (${block.language})`;
}
text += ':\n\n';
text += block.code + '\n\n';
});
}
}
return text.trim();
}
/**
* Strip HTML tags from content
*/
private stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '');
}
/**
* Decode HTML entities
*/
private decodeHtmlEntities(text: string): string {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
}
}

View File

@ -0,0 +1,170 @@
/**
* Note Creation Tool
*
* This tool allows the LLM to create new notes in Trilium.
*/
import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import becca from '../../../becca/becca.js';
import notes from '../../notes.js';
/**
* Definition of the note creation tool
*/
export const noteCreationToolDefinition: Tool = {
type: 'function',
function: {
name: 'create_note',
description: 'Create a new note in Trilium with the specified content and attributes',
parameters: {
type: 'object',
properties: {
parentNoteId: {
type: 'string',
description: 'ID of the parent note under which to create the new note. If not specified, creates under root.'
},
title: {
type: 'string',
description: 'Title of the new note'
},
content: {
type: 'string',
description: 'Content of the new note'
},
type: {
type: 'string',
description: 'Type of the note (text, code, etc.)',
enum: ['text', 'code', 'file', 'image', 'search', 'relation-map', 'book', 'mermaid', 'canvas']
},
mime: {
type: 'string',
description: 'MIME type of the note (e.g., text/html, application/json). Only required for certain note types.'
},
attributes: {
type: 'array',
description: 'Array of attributes to set on the note (e.g., [{"name":"#tag"}, {"name":"priority", "value":"high"}])',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name of the attribute'
},
value: {
type: 'string',
description: 'Value of the attribute (if applicable)'
}
}
}
}
},
required: ['title', 'content']
}
}
};
/**
* Note creation tool implementation
*/
export class NoteCreationTool implements ToolHandler {
public definition: Tool = noteCreationToolDefinition;
/**
* Execute the note creation tool
*/
public async execute(args: {
parentNoteId?: string,
title: string,
content: string,
type?: string,
mime?: string,
attributes?: Array<{ name: string, value?: string }>
}): Promise<string | object> {
try {
const { parentNoteId, title, content, type = 'text', mime } = args;
log.info(`Executing create_note tool - Title: "${title}", Type: ${type}, ParentNoteId: ${parentNoteId || 'root'}`);
// Validate parent note exists if specified
let parent;
if (parentNoteId) {
parent = becca.notes[parentNoteId];
if (!parent) {
return `Error: Parent note with ID ${parentNoteId} not found. Please specify a valid parent note ID.`;
}
} else {
// Use root note if no parent specified
parent = becca.getNote('root');
}
// Determine the appropriate mime type
let noteMime = mime;
if (!noteMime) {
// Set default mime types based on note type
switch (type) {
case 'text':
noteMime = 'text/html';
break;
case 'code':
noteMime = 'text/plain';
break;
case 'file':
noteMime = 'application/octet-stream';
break;
case 'image':
noteMime = 'image/png';
break;
default:
noteMime = 'text/html';
}
}
// Create the note
const createStartTime = Date.now();
const noteId = await notes.createNewNote({
parentNoteId: parent.noteId,
title: title,
content: content,
type: type,
mime: noteMime
});
const createDuration = Date.now() - createStartTime;
if (!noteId) {
return 'Error: Failed to create note. An unknown error occurred.';
}
log.info(`Note created successfully in ${createDuration}ms, ID: ${noteId}`);
// Add attributes if specified
if (args.attributes && args.attributes.length > 0) {
log.info(`Adding ${args.attributes.length} attributes to the note`);
for (const attr of args.attributes) {
if (!attr.name) continue;
const attrStartTime = Date.now();
await notes.createAttribute(noteId, attr.name, attr.value || '');
const attrDuration = Date.now() - attrStartTime;
log.info(`Added attribute ${attr.name}=${attr.value || ''} in ${attrDuration}ms`);
}
}
// Return the new note's information
const newNote = becca.notes[noteId];
return {
success: true,
noteId: noteId,
title: newNote.title,
type: newNote.type,
message: `Note "${title}" created successfully`
};
} catch (error: any) {
log.error(`Error executing create_note tool: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
}
}

View File

@ -0,0 +1,185 @@
/**
* Note Summarization Tool
*
* This tool allows the LLM to generate concise summaries of longer notes.
*/
import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import becca from '../../../becca/becca.js';
import aiServiceManager from '../ai_service_manager.js';
/**
* Definition of the note summarization tool
*/
export const noteSummarizationToolDefinition: Tool = {
type: 'function',
function: {
name: 'summarize_note',
description: 'Generate a concise summary of a note\'s content',
parameters: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'ID of the note to summarize'
},
maxLength: {
type: 'number',
description: 'Maximum length of the summary in characters (default: 500)'
},
format: {
type: 'string',
description: 'Format of the summary',
enum: ['paragraph', 'bullets', 'executive']
},
focus: {
type: 'string',
description: 'Optional focus for the summary (e.g., "technical details", "key findings")'
}
},
required: ['noteId']
}
}
};
/**
* Note summarization tool implementation
*/
export class NoteSummarizationTool implements ToolHandler {
public definition: Tool = noteSummarizationToolDefinition;
/**
* Execute the note summarization tool
*/
public async execute(args: {
noteId: string,
maxLength?: number,
format?: 'paragraph' | 'bullets' | 'executive',
focus?: string
}): Promise<string | object> {
try {
const { noteId, maxLength = 500, format = 'paragraph', focus } = args;
log.info(`Executing summarize_note tool - NoteID: "${noteId}", MaxLength: ${maxLength}, Format: ${format}`);
// Get the note from becca
const note = becca.notes[noteId];
if (!note) {
log.info(`Note with ID ${noteId} not found - returning error`);
return `Error: Note with ID ${noteId} not found`;
}
log.info(`Found note: "${note.title}" (Type: ${note.type})`);
// Get the note content
const content = await note.getContent();
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return {
success: false,
message: 'Note content is empty or invalid'
};
}
log.info(`Retrieved note content, length: ${content.length} chars`);
// Check if content needs summarization (if it's short enough, just return it)
if (content.length <= maxLength && !focus) {
log.info(`Note content is already shorter than maxLength, returning as is`);
return {
success: true,
noteId: note.noteId,
title: note.title,
summary: this.cleanHtml(content),
wasAlreadyShort: true
};
}
// Remove HTML tags for summarization
const cleanContent = this.cleanHtml(content);
// Generate the summary using the AI service
const aiService = aiServiceManager.getService();
if (!aiService) {
log.error('No AI service available for summarization');
return `Error: No AI service is available for summarization`;
}
log.info(`Using ${aiService.getName()} to generate summary`);
// Create a prompt based on format and focus
let prompt = `Summarize the following text`;
if (focus) {
prompt += ` with a focus on ${focus}`;
}
if (format === 'bullets') {
prompt += ` in a bullet point format`;
} else if (format === 'executive') {
prompt += ` as a brief executive summary`;
} else {
prompt += ` in a concise paragraph`;
}
prompt += `. Keep the summary under ${maxLength} characters:\n\n${cleanContent}`;
// Generate the summary
const summaryStartTime = Date.now();
const completion = await aiService.generateChatCompletion([
{ role: 'system', content: 'You are a skilled summarizer. Create concise, accurate summaries while preserving the key information.' },
{ role: 'user', content: prompt }
], {
temperature: 0.3, // Lower temperature for more focused summaries
maxTokens: 1000 // Enough tokens for the summary
});
const summaryDuration = Date.now() - summaryStartTime;
log.info(`Generated summary in ${summaryDuration}ms, length: ${completion.text.length} chars`);
return {
success: true,
noteId: note.noteId,
title: note.title,
originalLength: content.length,
summary: completion.text,
format: format,
focus: focus || 'general content'
};
} catch (error: any) {
log.error(`Error executing summarize_note tool: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
}
/**
* Clean HTML content for summarization
*/
private cleanHtml(html: string): string {
if (typeof html !== 'string') {
return '';
}
// Remove HTML tags
let text = html.replace(/<[^>]*>/g, '');
// Decode common HTML entities
text = text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
// Normalize whitespace
text = text.replace(/\s+/g, ' ').trim();
return text;
}
}

View File

@ -0,0 +1,136 @@
/**
* Note Update Tool
*
* This tool allows the LLM to update existing notes in Trilium.
*/
import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import becca from '../../../becca/becca.js';
/**
* Definition of the note update tool
*/
export const noteUpdateToolDefinition: Tool = {
type: 'function',
function: {
name: 'update_note',
description: 'Update the content or title of an existing note',
parameters: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'ID of the note to update'
},
title: {
type: 'string',
description: 'New title for the note (if you want to change it)'
},
content: {
type: 'string',
description: 'New content for the note (if you want to change it)'
},
mode: {
type: 'string',
description: 'How to update content: replace (default), append, or prepend',
enum: ['replace', 'append', 'prepend']
}
},
required: ['noteId']
}
}
};
/**
* Note update tool implementation
*/
export class NoteUpdateTool implements ToolHandler {
public definition: Tool = noteUpdateToolDefinition;
/**
* Execute the note update tool
*/
public async execute(args: { noteId: string, title?: string, content?: string, mode?: 'replace' | 'append' | 'prepend' }): Promise<string | object> {
try {
const { noteId, title, content, mode = 'replace' } = args;
if (!title && !content) {
return 'Error: At least one of title or content must be provided to update a note.';
}
log.info(`Executing update_note tool - NoteID: "${noteId}", Mode: ${mode}`);
// Get the note from becca
const note = becca.notes[noteId];
if (!note) {
log.info(`Note with ID ${noteId} not found - returning error`);
return `Error: Note with ID ${noteId} not found`;
}
log.info(`Found note: "${note.title}" (Type: ${note.type})`);
let titleUpdateResult;
let contentUpdateResult;
// Update title if provided
if (title && title !== note.title) {
const titleStartTime = Date.now();
try {
await note.setTitle(title);
const titleDuration = Date.now() - titleStartTime;
log.info(`Updated note title to "${title}" in ${titleDuration}ms`);
titleUpdateResult = `Title updated from "${note.title}" to "${title}"`;
} catch (error: any) {
log.error(`Error updating note title: ${error.message || String(error)}`);
titleUpdateResult = `Failed to update title: ${error.message || 'Unknown error'}`;
}
}
// Update content if provided
if (content) {
const contentStartTime = Date.now();
try {
let newContent = content;
// For append or prepend modes, get the current content first
if (mode === 'append' || mode === 'prepend') {
const currentContent = await note.getContent();
if (mode === 'append') {
newContent = currentContent + '\n\n' + content;
log.info(`Appending content to existing note content`);
} else if (mode === 'prepend') {
newContent = content + '\n\n' + currentContent;
log.info(`Prepending content to existing note content`);
}
}
await note.setContent(newContent);
const contentDuration = Date.now() - contentStartTime;
log.info(`Updated note content in ${contentDuration}ms, new content length: ${newContent.length}`);
contentUpdateResult = `Content updated successfully (${mode} mode)`;
} catch (error: any) {
log.error(`Error updating note content: ${error.message || String(error)}`);
contentUpdateResult = `Failed to update content: ${error.message || 'Unknown error'}`;
}
}
// Return the results
return {
success: true,
noteId: note.noteId,
title: note.title,
titleUpdate: titleUpdateResult || 'No title update requested',
contentUpdate: contentUpdateResult || 'No content update requested',
message: `Note "${note.title}" updated successfully`
};
} catch (error: any) {
log.error(`Error executing update_note tool: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
}
}

View File

@ -0,0 +1,382 @@
/**
* Relationship Tool
*
* This tool allows the LLM to create, identify, or modify relationships between notes.
*/
import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import becca from '../../../becca/becca.js';
import attributes from '../../attributes.js';
import aiServiceManager from '../ai_service_manager.js';
/**
* Definition of the relationship tool
*/
export const relationshipToolDefinition: Tool = {
type: 'function',
function: {
name: 'manage_relationships',
description: 'Create, list, or modify relationships between notes',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
description: 'Action to perform on relationships',
enum: ['create', 'list', 'find_related', 'suggest']
},
sourceNoteId: {
type: 'string',
description: 'ID of the source note for the relationship'
},
targetNoteId: {
type: 'string',
description: 'ID of the target note for the relationship (for create action)'
},
relationName: {
type: 'string',
description: 'Name of the relation (for create action, e.g., "references", "belongs to", "depends on")'
},
limit: {
type: 'number',
description: 'Maximum number of relationships to return (for list action)'
}
},
required: ['action', 'sourceNoteId']
}
}
};
/**
* Relationship tool implementation
*/
export class RelationshipTool implements ToolHandler {
public definition: Tool = relationshipToolDefinition;
/**
* Execute the relationship tool
*/
public async execute(args: {
action: 'create' | 'list' | 'find_related' | 'suggest',
sourceNoteId: string,
targetNoteId?: string,
relationName?: string,
limit?: number
}): Promise<string | object> {
try {
const { action, sourceNoteId, targetNoteId, relationName, limit = 10 } = args;
log.info(`Executing manage_relationships tool - Action: ${action}, SourceNoteId: ${sourceNoteId}`);
// Get the source note from becca
const sourceNote = becca.notes[sourceNoteId];
if (!sourceNote) {
log.info(`Source note with ID ${sourceNoteId} not found - returning error`);
return `Error: Source note with ID ${sourceNoteId} not found`;
}
log.info(`Found source note: "${sourceNote.title}" (Type: ${sourceNote.type})`);
// Handle different actions
if (action === 'create') {
return await this.createRelationship(sourceNote, targetNoteId, relationName);
} else if (action === 'list') {
return await this.listRelationships(sourceNote, limit);
} else if (action === 'find_related') {
return await this.findRelatedNotes(sourceNote, limit);
} else if (action === 'suggest') {
return await this.suggestRelationships(sourceNote, limit);
} else {
return `Error: Unsupported action "${action}". Supported actions are: create, list, find_related, suggest`;
}
} catch (error: any) {
log.error(`Error executing manage_relationships tool: ${error.message || String(error)}`);
return `Error: ${error.message || String(error)}`;
}
}
/**
* Create a relationship between notes
*/
private async createRelationship(sourceNote: any, targetNoteId?: string, relationName?: string): Promise<object> {
if (!targetNoteId) {
return {
success: false,
message: 'Target note ID is required for create action'
};
}
if (!relationName) {
return {
success: false,
message: 'Relation name is required for create action'
};
}
// Get the target note from becca
const targetNote = becca.notes[targetNoteId];
if (!targetNote) {
log.info(`Target note with ID ${targetNoteId} not found - returning error`);
return {
success: false,
message: `Target note with ID ${targetNoteId} not found`
};
}
log.info(`Found target note: "${targetNote.title}" (Type: ${targetNote.type})`);
try {
// Check if relationship already exists
const existingRelations = sourceNote.getRelationTargets(relationName);
for (const existingNote of existingRelations) {
if (existingNote.noteId === targetNoteId) {
log.info(`Relationship ${relationName} already exists from "${sourceNote.title}" to "${targetNote.title}"`);
return {
success: false,
sourceNoteId: sourceNote.noteId,
sourceTitle: sourceNote.title,
targetNoteId: targetNote.noteId,
targetTitle: targetNote.title,
relationName: relationName,
message: `Relationship ${relationName} already exists from "${sourceNote.title}" to "${targetNote.title}"`
};
}
}
// Create the relationship attribute
const startTime = Date.now();
await attributes.createRelation(sourceNote.noteId, relationName, targetNote.noteId);
const duration = Date.now() - startTime;
log.info(`Created relationship ${relationName} from "${sourceNote.title}" to "${targetNote.title}" in ${duration}ms`);
return {
success: true,
sourceNoteId: sourceNote.noteId,
sourceTitle: sourceNote.title,
targetNoteId: targetNote.noteId,
targetTitle: targetNote.title,
relationName: relationName,
message: `Created relationship ${relationName} from "${sourceNote.title}" to "${targetNote.title}"`
};
} catch (error: any) {
log.error(`Error creating relationship: ${error.message || String(error)}`);
throw error;
}
}
/**
* List relationships for a note
*/
private async listRelationships(sourceNote: any, limit: number): Promise<object> {
try {
// Get outgoing relationships (where this note is the source)
const outgoingAttributes = sourceNote.getAttributes()
.filter((attr: any) => attr.type === 'relation')
.slice(0, limit);
const outgoingRelations = [];
for (const attr of outgoingAttributes) {
const targetNote = becca.notes[attr.value];
if (targetNote) {
outgoingRelations.push({
relationName: attr.name,
targetNoteId: targetNote.noteId,
targetTitle: targetNote.title
});
}
}
// Get incoming relationships (where this note is the target)
const incomingNotes = becca.findNotesWithRelation(sourceNote.noteId);
const incomingRelations = [];
for (const sourceOfRelation of incomingNotes) {
const incomingAttributes = sourceOfRelation.getOwnedAttributes()
.filter((attr: any) => attr.type === 'relation' && attr.value === sourceNote.noteId);
for (const attr of incomingAttributes) {
incomingRelations.push({
relationName: attr.name,
sourceNoteId: sourceOfRelation.noteId,
sourceTitle: sourceOfRelation.title
});
}
if (incomingRelations.length >= limit) {
break;
}
}
log.info(`Found ${outgoingRelations.length} outgoing and ${incomingRelations.length} incoming relationships`);
return {
success: true,
noteId: sourceNote.noteId,
title: sourceNote.title,
outgoingRelations: outgoingRelations,
incomingRelations: incomingRelations.slice(0, limit),
message: `Found ${outgoingRelations.length} outgoing and ${incomingRelations.length} incoming relationships for "${sourceNote.title}"`
};
} catch (error: any) {
log.error(`Error listing relationships: ${error.message || String(error)}`);
throw error;
}
}
/**
* Find related notes using vector similarity
*/
private async findRelatedNotes(sourceNote: any, limit: number): Promise<object> {
try {
// Get the vector search tool from the AI service manager
const vectorSearchTool = aiServiceManager.getVectorSearchTool();
if (!vectorSearchTool) {
log.error('Vector search tool not available');
return {
success: false,
message: 'Vector search capability not available'
};
}
log.info(`Using vector search to find notes related to "${sourceNote.title}"`);
// Get note content for semantic search
const content = await sourceNote.getContent();
const title = sourceNote.title;
// Use both title and content for search
const searchQuery = title + (content && typeof content === 'string' ? ': ' + content.substring(0, 500) : '');
// Execute the search
const searchStartTime = Date.now();
const results = await vectorSearchTool.searchNotes(searchQuery, {
maxResults: limit + 1 // Add 1 to account for the source note itself
});
const searchDuration = Date.now() - searchStartTime;
// Filter out the source note from results
const filteredResults = results.filter(note => note.noteId !== sourceNote.noteId);
log.info(`Found ${filteredResults.length} related notes in ${searchDuration}ms`);
return {
success: true,
noteId: sourceNote.noteId,
title: sourceNote.title,
relatedNotes: filteredResults.slice(0, limit).map(note => ({
noteId: note.noteId,
title: note.title,
similarity: Math.round(note.similarity * 100) / 100
})),
message: `Found ${filteredResults.length} notes semantically related to "${sourceNote.title}"`
};
} catch (error: any) {
log.error(`Error finding related notes: ${error.message || String(error)}`);
throw error;
}
}
/**
* Suggest possible relationships based on content analysis
*/
private async suggestRelationships(sourceNote: any, limit: number): Promise<object> {
try {
// First, find related notes using vector search
const relatedResult = await this.findRelatedNotes(sourceNote, limit) as any;
if (!relatedResult.success || !relatedResult.relatedNotes || relatedResult.relatedNotes.length === 0) {
return {
success: false,
message: 'Could not find any related notes to suggest relationships'
};
}
// Get the AI service for relationship suggestion
const aiService = aiServiceManager.getService();
if (!aiService) {
log.error('No AI service available for relationship suggestions');
return {
success: false,
message: 'AI service not available for relationship suggestions',
relatedNotes: relatedResult.relatedNotes
};
}
log.info(`Using ${aiService.getName()} to suggest relationships for ${relatedResult.relatedNotes.length} related notes`);
// Get the source note content
const sourceContent = await sourceNote.getContent();
// Prepare suggestions
const suggestions = [];
for (const relatedNote of relatedResult.relatedNotes) {
try {
// Get the target note content
const targetNote = becca.notes[relatedNote.noteId];
const targetContent = await targetNote.getContent();
// Prepare a prompt for the AI service
const prompt = `Analyze the relationship between these two notes and suggest a descriptive relation name (like "references", "implements", "depends on", etc.)
SOURCE NOTE: "${sourceNote.title}"
${typeof sourceContent === 'string' ? sourceContent.substring(0, 300) : ''}
TARGET NOTE: "${targetNote.title}"
${typeof targetContent === 'string' ? targetContent.substring(0, 300) : ''}
Suggest the most appropriate relationship type that would connect the source note to the target note. Reply with ONLY the relationship name, nothing else.`;
// Get the suggestion
const completion = await aiService.generateChatCompletion([
{
role: 'system',
content: 'You analyze the relationship between notes and suggest a concise, descriptive relation name.'
},
{ role: 'user', content: prompt }
], {
temperature: 0.4,
maxTokens: 50
});
// Extract just the relation name (remove any formatting or explanation)
const relationName = completion.text
.replace(/^["']|["']$/g, '') // Remove quotes
.replace(/^relationship:|\./gi, '') // Remove prefixes/suffixes
.trim();
suggestions.push({
targetNoteId: relatedNote.noteId,
targetTitle: relatedNote.title,
similarity: relatedNote.similarity,
suggestedRelation: relationName
});
log.info(`Suggested relationship "${relationName}" from "${sourceNote.title}" to "${targetNote.title}"`);
} catch (error: any) {
log.error(`Error generating suggestion: ${error.message || String(error)}`);
// Continue with other suggestions
}
}
return {
success: true,
noteId: sourceNote.noteId,
title: sourceNote.title,
suggestions: suggestions,
message: `Generated ${suggestions.length} relationship suggestions for "${sourceNote.title}"`
};
} catch (error: any) {
log.error(`Error suggesting relationships: ${error.message || String(error)}`);
throw error;
}
}
}