refactor "context" services

This commit is contained in:
perf3ct 2025-03-19 19:28:02 +00:00
parent 352204bf78
commit db4dd6d2ef
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
16 changed files with 1671 additions and 1373 deletions

View File

@ -9,7 +9,7 @@ import providerManager from "../../services/llm/embeddings/providers.js";
import type { Message, ChatCompletionOptions } from "../../services/llm/ai_interface.js";
// Import this way to prevent immediate instantiation
import * as aiServiceManagerModule from "../../services/llm/ai_service_manager.js";
import triliumContextService from "../../services/llm/trilium_context_service.js";
import contextService from "../../services/llm/context_service.js";
import sql from "../../services/sql.js";
// Import the index service for knowledge base management
import indexService from "../../services/llm/index_service.js";
@ -653,14 +653,14 @@ async function sendMessage(req: Request, res: Response) {
// Use the Trilium-specific approach
const contextNoteId = session.noteContext || null;
// Log that we're calling triliumContextService with the parameters
// Log that we're calling contextService with the parameters
log.info(`Using enhanced context with: noteId=${contextNoteId}, showThinking=${showThinking}`);
const results = await triliumContextService.processQuery(
const results = await contextService.processQuery(
messageContent,
service,
contextNoteId,
showThinking // Pass the showThinking parameter
showThinking
);
// Get the generated context

144
src/services/llm/README.md Normal file
View File

@ -0,0 +1,144 @@
# Trilium Context Service
This directory contains Trilium's context management services, which are responsible for providing relevant context to LLM models when generating responses.
## Structure
The context system has been refactored into a modular architecture:
```
context/
├── index.ts - Base context extractor
├── semantic_context.ts - Semantic context utilities
├── hierarchy.ts - Note hierarchy context utilities
├── code_handlers.ts - Code-specific context handling
├── content_chunking.ts - Content chunking utilities
├── note_content.ts - Note content processing
├── summarization.ts - Content summarization utilities
├── modules/ - Modular context services
│ ├── provider_manager.ts - Embedding provider management
│ ├── cache_manager.ts - Caching system
│ ├── semantic_search.ts - Semantic search functionality
│ ├── query_enhancer.ts - Query enhancement
│ ├── context_formatter.ts - Context formatting
│ └── context_service.ts - Main context service
└── README.md - This documentation
```
## Main Entry Points
- `context_service.ts` - Main entry point for modern code
- `semantic_context_service.ts` - Compatibility wrapper for old code (deprecated)
- `trilium_context_service.ts` - Compatibility wrapper for old code (deprecated)
## Usage
### For new code:
```typescript
import aiServiceManager from '../services/llm/ai_service_manager.js';
// Get the context service
const contextService = aiServiceManager.getContextService();
// Process a query to get relevant context
const result = await contextService.processQuery(
"What are my notes about programming?",
llmService,
currentNoteId,
false // showThinking
);
// Get semantic context
const context = await contextService.getSemanticContext(noteId, userQuery);
// Get context that adapts to query complexity
const smartContext = await contextService.getSmartContext(noteId, userQuery);
```
### For legacy code (deprecated):
```typescript
import aiServiceManager from '../services/llm/ai_service_manager.js';
// Get the semantic context service (deprecated)
const semanticContext = aiServiceManager.getSemanticContextService();
// Get context
const context = await semanticContext.getSemanticContext(noteId, userQuery);
```
## Modules
### Provider Manager
Handles embedding provider selection and management:
```typescript
import providerManager from './context/modules/provider_manager.js';
// Get the preferred embedding provider
const provider = await providerManager.getPreferredEmbeddingProvider();
// Generate embeddings for a query
const embedding = await providerManager.generateQueryEmbedding(query);
```
### Cache Manager
Provides caching for context data:
```typescript
import cacheManager from './context/modules/cache_manager.js';
// Get cached data
const cached = cacheManager.getNoteData(noteId, 'content');
// Store data in cache
cacheManager.storeNoteData(noteId, 'content', data);
// Clear caches
cacheManager.clearAllCaches();
```
### Semantic Search
Handles semantic search functionality:
```typescript
import semanticSearch from './context/modules/semantic_search.js';
// Find relevant notes
const notes = await semanticSearch.findRelevantNotes(query, contextNoteId);
// Rank notes by relevance
const ranked = await semanticSearch.rankNotesByRelevance(notes, query);
```
### Query Enhancer
Provides query enhancement:
```typescript
import queryEnhancer from './context/modules/query_enhancer.js';
// Generate multiple search queries from a user question
const queries = await queryEnhancer.generateSearchQueries(question, llmService);
// Estimate query complexity
const complexity = queryEnhancer.estimateQueryComplexity(query);
```
### Context Formatter
Formats context for LLM consumption:
```typescript
import contextFormatter from './context/modules/context_formatter.js';
// Build formatted context from notes
const context = await contextFormatter.buildContextFromNotes(notes, query, providerId);
// Sanitize note content
const clean = contextFormatter.sanitizeNoteContent(content, type, mime);
```

View File

@ -11,7 +11,7 @@ import { QueryDecompositionTool } from './query_decomposition_tool.js';
import { ContextualThinkingTool } from './contextual_thinking_tool.js';
// Import services needed for initialization
import SemanticContextService from '../semantic_context_service.js';
import contextService from '../context_service.js';
import aiServiceManager from '../ai_service_manager.js';
import log from '../../log.js';
@ -43,15 +43,14 @@ export class AgentToolsManager {
this.queryDecompositionTool = new QueryDecompositionTool();
this.contextualThinkingTool = new ContextualThinkingTool();
// Get semantic context service and set it in the vector search tool
const semanticContext = aiServiceManager.getSemanticContextService();
this.vectorSearchTool.setSemanticContext(semanticContext);
// Set context service in the vector search tool
this.vectorSearchTool.setContextService(contextService);
this.initialized = true;
log.info("LLM agent tools initialized successfully");
} catch (error: any) {
log.error(`Failed to initialize LLM agent tools: ${error.message}`);
throw new Error(`Agent tools initialization failed: ${error.message}`);
} catch (error) {
log.error(`Failed to initialize agent tools: ${error}`);
throw error;
}
}

View File

@ -14,10 +14,10 @@
import log from '../../log.js';
// Define interface for semantic context service to avoid circular imports
interface ISemanticContextService {
semanticSearch(query: string, options: any): Promise<any[]>;
semanticSearchChunks(query: string, options: any): Promise<any[]>;
// Define interface for context service to avoid circular imports
interface IContextService {
findRelevantNotesMultiQuery(queries: string[], contextNoteId: string | null, limit: number): Promise<any[]>;
processQuery(userQuestion: string, llmService: any, contextNoteId: string | null, showThinking: boolean): Promise<any>;
}
export interface VectorSearchResult {
@ -49,22 +49,23 @@ export interface ChunkSearchResultItem {
}
export class VectorSearchTool {
private semanticContext: ISemanticContextService | null = null;
private contextService: IContextService | null = null;
private maxResults: number = 5;
constructor() {
// The semantic context will be set later via setSemanticContext
// Initialization is done by setting context service
}
/**
* Set the semantic context service instance
* Set the context service for performing vector searches
*/
setSemanticContext(semanticContext: ISemanticContextService): void {
this.semanticContext = semanticContext;
setContextService(contextService: IContextService): void {
this.contextService = contextService;
log.info('Context service set in VectorSearchTool');
}
/**
* Search for notes semantically related to a query
* Search for notes that are semantically related to the query
*/
async searchNotes(query: string, options: {
parentNoteId?: string,
@ -72,47 +73,44 @@ export class VectorSearchTool {
similarityThreshold?: number
} = {}): Promise<VectorSearchResult[]> {
try {
if (!this.semanticContext) {
throw new Error("Semantic context service not set. Call setSemanticContext() first.");
}
if (!query || query.trim().length === 0) {
// Validate contextService is set
if (!this.contextService) {
log.error('Context service not set in VectorSearchTool');
return [];
}
// Set defaults
const maxResults = options.maxResults || this.maxResults;
const similarityThreshold = options.similarityThreshold || 0.65; // Default threshold
const parentNoteId = options.parentNoteId; // Optional filtering by parent
const parentNoteId = options.parentNoteId || null;
// Search notes using the semantic context service
const results = await this.semanticContext.semanticSearch(query, {
maxResults,
similarityThreshold,
ancestorNoteId: parentNoteId
});
// Use multi-query approach for more robust results
const queries = [query];
const results = await this.contextService.findRelevantNotesMultiQuery(
queries,
parentNoteId,
maxResults
);
if (!results || results.length === 0) {
return [];
}
// Transform results to the tool's format
return results.map((result: SearchResultItem) => ({
// Format results to match the expected interface
return results.map(result => ({
noteId: result.noteId,
title: result.noteTitle,
contentPreview: result.contentPreview,
title: result.title,
contentPreview: result.content ?
(result.content.length > 200 ?
result.content.substring(0, 200) + '...' :
result.content)
: 'No content available',
similarity: result.similarity,
parentId: result.parentId,
dateCreated: result.dateCreated,
dateModified: result.dateModified
parentId: result.parentId
}));
} catch (error: any) {
log.error(`Error in vector search: ${error.message}`);
} catch (error) {
log.error(`Error in vector search: ${error}`);
return [];
}
}
/**
* Search for content chunks within notes that are semantically related to a query
* Search for content chunks that are semantically related to the query
*/
async searchContentChunks(query: string, options: {
noteId?: string,
@ -120,39 +118,15 @@ export class VectorSearchTool {
similarityThreshold?: number
} = {}): Promise<VectorSearchResult[]> {
try {
if (!this.semanticContext) {
throw new Error("Semantic context service not set. Call setSemanticContext() first.");
}
if (!query || query.trim().length === 0) {
return [];
}
const maxResults = options.maxResults || this.maxResults;
const similarityThreshold = options.similarityThreshold || 0.70; // Higher threshold for chunks
const noteId = options.noteId; // Optional filtering by specific note
// Search content chunks using the semantic context service
const results = await this.semanticContext.semanticSearchChunks(query, {
maxResults,
similarityThreshold,
noteId
// For now, use the same implementation as searchNotes,
// but in the future we'll implement chunk-based search
return this.searchNotes(query, {
parentNoteId: options.noteId,
maxResults: options.maxResults,
similarityThreshold: options.similarityThreshold
});
if (!results || results.length === 0) {
return [];
}
// Transform results to the tool's format
return results.map((result: ChunkSearchResultItem) => ({
noteId: result.noteId,
title: result.noteTitle,
contentPreview: result.chunk, // Use the chunk content as preview
similarity: result.similarity,
parentId: result.parentId
}));
} catch (error: any) {
log.error(`Error in content chunk search: ${error.message}`);
} catch (error) {
log.error(`Error in vector chunk search: ${error}`);
return [];
}
}

View File

@ -5,7 +5,7 @@ import { AnthropicService } from './providers/anthropic_service.js';
import { OllamaService } from './providers/ollama_service.js';
import log from '../log.js';
import { ContextExtractor } from './context/index.js';
import semanticContextService from './semantic_context_service.js';
import contextService from './context_service.js';
import indexService from './index_service.js';
import { getEmbeddingProvider, getEnabledEmbeddingProviders } from './embeddings/providers.js';
import agentTools from './agent_tools/index.js';
@ -268,11 +268,21 @@ export class AIServiceManager {
}
/**
* Get the semantic context service for advanced context handling
* Get the semantic context service for enhanced context management
* @deprecated Use getContextService() instead
* @returns The semantic context service instance
*/
getSemanticContextService(): SemanticContextService {
return semanticContextService as unknown as SemanticContextService;
log.info('getSemanticContextService is deprecated, use getContextService instead');
return contextService as unknown as SemanticContextService;
}
/**
* Get the context service for advanced context management
* @returns The context service instance
*/
getContextService() {
return contextService;
}
/**
@ -404,6 +414,23 @@ export class AIServiceManager {
throw error;
}
}
/**
* Get context enhanced with agent tools
*/
async getAgentToolsContext(
noteId: string,
query: string,
showThinking: boolean = false,
relevantNotes: Array<any> = []
): Promise<string> {
return contextService.getAgentToolsContext(
noteId,
query,
showThinking,
relevantNotes
);
}
}
// Don't create singleton immediately, use a lazy-loading pattern
@ -442,6 +469,9 @@ export default {
getSemanticContextService(): SemanticContextService {
return getInstance().getSemanticContextService();
},
getContextService() {
return getInstance().getContextService();
},
getIndexService() {
return getInstance().getIndexService();
},
@ -464,6 +494,19 @@ export default {
},
getContextualThinkingTool() {
return getInstance().getContextualThinkingTool();
},
async getAgentToolsContext(
noteId: string,
query: string,
showThinking: boolean = false,
relevantNotes: Array<any> = []
): Promise<string> {
return getInstance().getAgentToolsContext(
noteId,
query,
showThinking,
relevantNotes
);
}
};

View File

@ -1,10 +1,6 @@
import type { Message, ChatCompletionOptions } from './ai_interface.js';
import aiServiceManager from './ai_service_manager.js';
import chatStorageService from './chat_storage_service.js';
import { ContextExtractor } from './context/index.js';
// Create an instance of ContextExtractor for backward compatibility
const contextExtractor = new ContextExtractor();
export interface ChatSession {
id: string;
@ -201,7 +197,8 @@ export class ChatService {
const session = await this.getOrCreateSession(sessionId);
// Use semantic context that considers the query for better relevance
const context = await contextExtractor.getSemanticContext(noteId, query);
const contextService = aiServiceManager.getContextService();
const context = await contextService.getSemanticContext(noteId, query);
const contextMessage: Message = {
role: 'user',
@ -245,7 +242,7 @@ export class ChatService {
await chatStorageService.updateChat(session.id, session.messages);
// Get the Trilium Context Service for enhanced context
const contextService = aiServiceManager.getSemanticContextService();
const contextService = aiServiceManager.getContextService();
// Get showThinking option if it exists
const showThinking = options?.showThinking === true;

View File

@ -467,16 +467,15 @@ export class ContextExtractor {
*/
static async getProgressiveContext(noteId: string, depth = 1): Promise<string> {
try {
// This requires the semantic context service to be available
// We're using a dynamic import to avoid circular dependencies
// Use the new context service
const { default: aiServiceManager } = await import('../ai_service_manager.js');
const semanticContext = aiServiceManager.getInstance().getSemanticContextService();
const contextService = aiServiceManager.getInstance().getContextService();
if (!semanticContext) {
if (!contextService) {
return ContextExtractor.extractContext(noteId);
}
return await semanticContext.getProgressiveContext(noteId, depth);
return await contextService.getProgressiveContext(noteId, depth);
} catch (error) {
// Fall back to regular context if progressive loading fails
console.error('Error in progressive context loading:', error);
@ -501,16 +500,15 @@ export class ContextExtractor {
*/
static async getSmartContext(noteId: string, query: string): Promise<string> {
try {
// This requires the semantic context service to be available
// We're using a dynamic import to avoid circular dependencies
// Use the new context service
const { default: aiServiceManager } = await import('../ai_service_manager.js');
const semanticContext = aiServiceManager.getInstance().getSemanticContextService();
const contextService = aiServiceManager.getInstance().getContextService();
if (!semanticContext) {
if (!contextService) {
return ContextExtractor.extractContext(noteId);
}
return await semanticContext.getSmartContext(noteId, query);
return await contextService.getSmartContext(noteId, query);
} catch (error) {
// Fall back to regular context if smart context fails
console.error('Error in smart context selection:', error);

View File

@ -0,0 +1,122 @@
import log from '../../../log.js';
/**
* Manages caching for context services
* Provides a centralized caching system to avoid redundant operations
*/
export class CacheManager {
// Cache for recently used context to avoid repeated embedding lookups
private noteDataCache = new Map<string, {
timestamp: number,
data: any
}>();
// Cache for recently used queries
private queryCache = new Map<string, {
timestamp: number,
results: any
}>();
// Default cache expiry (5 minutes)
private defaultCacheExpiryMs = 5 * 60 * 1000;
constructor() {
this.setupCacheCleanup();
}
/**
* Set up periodic cache cleanup
*/
private setupCacheCleanup() {
setInterval(() => {
this.cleanupCache();
}, 60000); // Run cleanup every minute
}
/**
* Clean up expired cache entries
*/
cleanupCache() {
const now = Date.now();
// Clean note data cache
for (const [key, data] of this.noteDataCache.entries()) {
if (now - data.timestamp > this.defaultCacheExpiryMs) {
this.noteDataCache.delete(key);
}
}
// Clean query cache
for (const [key, data] of this.queryCache.entries()) {
if (now - data.timestamp > this.defaultCacheExpiryMs) {
this.queryCache.delete(key);
}
}
}
/**
* Get cached note data
*/
getNoteData(noteId: string, type: string): any | null {
const key = `${noteId}:${type}`;
const cached = this.noteDataCache.get(key);
if (cached && Date.now() - cached.timestamp < this.defaultCacheExpiryMs) {
log.info(`Cache hit for note data: ${key}`);
return cached.data;
}
return null;
}
/**
* Store note data in cache
*/
storeNoteData(noteId: string, type: string, data: any): void {
const key = `${noteId}:${type}`;
this.noteDataCache.set(key, {
timestamp: Date.now(),
data
});
log.info(`Cached note data: ${key}`);
}
/**
* Get cached query results
*/
getQueryResults(query: string, contextNoteId: string | null = null): any | null {
const key = JSON.stringify({ query, contextNoteId });
const cached = this.queryCache.get(key);
if (cached && Date.now() - cached.timestamp < this.defaultCacheExpiryMs) {
log.info(`Cache hit for query: ${query}`);
return cached.results;
}
return null;
}
/**
* Store query results in cache
*/
storeQueryResults(query: string, results: any, contextNoteId: string | null = null): void {
const key = JSON.stringify({ query, contextNoteId });
this.queryCache.set(key, {
timestamp: Date.now(),
results
});
log.info(`Cached query results: ${query}`);
}
/**
* Clear all caches
*/
clearAllCaches(): void {
this.noteDataCache.clear();
this.queryCache.clear();
log.info('All context caches cleared');
}
}
// Export singleton instance
export default new CacheManager();

View File

@ -0,0 +1,164 @@
import sanitizeHtml from 'sanitize-html';
import log from '../../../log.js';
// Constants for context window sizes, defines in-module to avoid circular dependencies
const CONTEXT_WINDOW = {
OPENAI: 16000,
ANTHROPIC: 100000,
OLLAMA: 8000,
DEFAULT: 4000
};
/**
* Provides utilities for formatting context for LLM consumption
*/
export class ContextFormatter {
/**
* Build context string from retrieved notes
*
* @param sources - Array of notes or content sources
* @param query - The original user query
* @param providerId - The LLM provider to format for
* @returns Formatted context string
*/
async buildContextFromNotes(sources: any[], query: string, providerId: string = 'default'): Promise<string> {
if (!sources || sources.length === 0) {
// Return a default context instead of empty string
return "I am an AI assistant helping you with your Trilium notes. " +
"I couldn't find any specific notes related to your query, but I'll try to assist you " +
"with general knowledge about Trilium or other topics you're interested in.";
}
try {
// Get appropriate context size based on provider
const maxTotalLength =
providerId === 'openai' ? CONTEXT_WINDOW.OPENAI :
providerId === 'anthropic' ? CONTEXT_WINDOW.ANTHROPIC :
providerId === 'ollama' ? CONTEXT_WINDOW.OLLAMA :
CONTEXT_WINDOW.DEFAULT;
// Use a format appropriate for the model family
const isAnthropicFormat = providerId === 'anthropic';
// Start with different headers based on provider
let context = isAnthropicFormat
? `I'm your AI assistant helping with your Trilium notes database. For your query: "${query}", I found these relevant notes:\n\n`
: `I've found some relevant information in your notes that may help answer: "${query}"\n\n`;
// Sort sources by similarity if available to prioritize most relevant
if (sources[0] && sources[0].similarity !== undefined) {
sources = [...sources].sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
}
// Track total size to avoid exceeding model context window
let totalSize = context.length;
const formattedSources: string[] = [];
// Process each source
for (const source of sources) {
let content = '';
if (typeof source === 'string') {
content = source;
} else if (source.content) {
content = this.sanitizeNoteContent(source.content, source.type, source.mime);
} else {
continue; // Skip invalid sources
}
if (!content || content.trim().length === 0) {
continue;
}
// Format source with title if available
const title = source.title || 'Untitled Note';
const noteId = source.noteId || '';
const formattedSource = `### ${title}\n${content}\n`;
// Check if adding this would exceed our size limit
if (totalSize + formattedSource.length > maxTotalLength) {
// If this is the first source, include a truncated version
if (formattedSources.length === 0) {
const availableSpace = maxTotalLength - totalSize - 100; // Buffer for closing text
if (availableSpace > 200) { // Only if we have reasonable space
const truncatedContent = `### ${title}\n${content.substring(0, availableSpace)}...\n`;
formattedSources.push(truncatedContent);
totalSize += truncatedContent.length;
}
}
break;
}
formattedSources.push(formattedSource);
totalSize += formattedSource.length;
}
// Add the formatted sources to the context
context += formattedSources.join('\n');
// Add closing to provide instructions to the AI
const closing = isAnthropicFormat
? "\n\nPlease use this information to answer the user's query. If the notes don't contain enough information, you can use your general knowledge as well."
: "\n\nBased on this information from the user's notes, please provide a helpful response.";
// Check if adding the closing would exceed our limit
if (totalSize + closing.length <= maxTotalLength) {
context += closing;
}
return context;
} catch (error) {
log.error(`Error building context from notes: ${error}`);
return "I'm your AI assistant helping with your Trilium notes. I'll try to answer based on what I know.";
}
}
/**
* Sanitize note content for inclusion in context
*
* @param content - Raw note content
* @param type - Note type (text, code, etc.)
* @param mime - Note mime type
* @returns Sanitized content
*/
sanitizeNoteContent(content: string, type?: string, mime?: string): string {
if (!content) {
return '';
}
try {
// If it's HTML content, sanitize it
if (mime === 'text/html' || type === 'text') {
// Use sanitize-html to convert HTML to plain text
const sanitized = sanitizeHtml(content, {
allowedTags: [], // No tags allowed (strip all HTML)
allowedAttributes: {}, // No attributes allowed
textFilter: function(text) {
return text
.replace(/&nbsp;/g, ' ')
.replace(/\n\s*\n\s*\n/g, '\n\n'); // Replace multiple blank lines with just one
}
});
return sanitized.trim();
}
// If it's code, keep formatting but limit size
if (type === 'code' || mime?.includes('application/')) {
// For code, limit to a reasonable size
if (content.length > 2000) {
return content.substring(0, 2000) + '...\n\n[Content truncated for brevity]';
}
return content;
}
// For all other types, just return as is
return content;
} catch (error) {
log.error(`Error sanitizing note content: ${error}`);
return content; // Return original content if sanitization fails
}
}
}
// Export singleton instance
export default new ContextFormatter();

View File

@ -0,0 +1,368 @@
import log from '../../../log.js';
import providerManager from './provider_manager.js';
import cacheManager from './cache_manager.js';
import semanticSearch from './semantic_search.js';
import queryEnhancer from './query_enhancer.js';
import contextFormatter from './context_formatter.js';
import aiServiceManager from '../../ai_service_manager.js';
import { ContextExtractor } from '../index.js';
/**
* Main context service that integrates all context-related functionality
* This service replaces the old TriliumContextService and SemanticContextService
*/
export class ContextService {
private initialized = false;
private initPromise: Promise<void> | null = null;
private contextExtractor: ContextExtractor;
constructor() {
this.contextExtractor = new ContextExtractor();
}
/**
* Initialize the service
*/
async initialize(): Promise<void> {
if (this.initialized) return;
// Use a promise to prevent multiple simultaneous initializations
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
try {
// Initialize provider
const provider = await providerManager.getPreferredEmbeddingProvider();
if (!provider) {
throw new Error(`No embedding provider available. Could not initialize context service.`);
}
// Initialize agent tools to ensure they're ready
try {
await aiServiceManager.getInstance().initializeAgentTools();
log.info("Agent tools initialized for use with ContextService");
} catch (toolError) {
log.error(`Error initializing agent tools: ${toolError}`);
// Continue even if agent tools fail to initialize
}
this.initialized = true;
log.info(`Context service initialized with provider: ${provider.name}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Failed to initialize context service: ${errorMessage}`);
throw error;
} finally {
this.initPromise = null;
}
})();
return this.initPromise;
}
/**
* Process a user query to find relevant context in Trilium notes
*
* @param userQuestion - The user's query
* @param llmService - The LLM service to use
* @param contextNoteId - Optional note ID to restrict search to a branch
* @param showThinking - Whether to show the thinking process in output
* @returns Context information and relevant notes
*/
async processQuery(
userQuestion: string,
llmService: any,
contextNoteId: string | null = null,
showThinking: boolean = false
) {
log.info(`Processing query with: question="${userQuestion.substring(0, 50)}...", noteId=${contextNoteId}, showThinking=${showThinking}`);
if (!this.initialized) {
try {
await this.initialize();
} catch (error) {
log.error(`Failed to initialize ContextService: ${error}`);
// Return a fallback response if initialization fails
return {
context: "I am an AI assistant helping you with your Trilium notes. " +
"I'll try to assist you with general knowledge about your query.",
notes: [],
queries: [userQuestion]
};
}
}
try {
// Step 1: Generate search queries
let searchQueries: string[];
try {
searchQueries = await queryEnhancer.generateSearchQueries(userQuestion, llmService);
} catch (error) {
log.error(`Error generating search queries, using fallback: ${error}`);
searchQueries = [userQuestion]; // Fallback to using the original question
}
log.info(`Generated search queries: ${JSON.stringify(searchQueries)}`);
// Step 2: Find relevant notes using multi-query approach
let relevantNotes: any[] = [];
try {
// Find notes for each query and combine results
const allResults: Map<string, any> = new Map();
for (const query of searchQueries) {
const results = await semanticSearch.findRelevantNotes(
query,
contextNoteId,
5 // Limit per query
);
// Combine results, avoiding duplicates
for (const result of results) {
if (!allResults.has(result.noteId)) {
allResults.set(result.noteId, result);
} else {
// If note already exists, update similarity to max of both values
const existing = allResults.get(result.noteId);
if (result.similarity > existing.similarity) {
existing.similarity = result.similarity;
allResults.set(result.noteId, existing);
}
}
}
}
// Convert map to array and limit to top results
relevantNotes = Array.from(allResults.values())
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 8); // Get top 8 notes
} catch (error) {
log.error(`Error finding relevant notes: ${error}`);
// Continue with empty notes list
}
// Step 3: Build context from the notes
const provider = await providerManager.getPreferredEmbeddingProvider();
const providerId = provider?.name || 'default';
const context = await contextFormatter.buildContextFromNotes(relevantNotes, userQuestion, providerId);
// Step 4: Add agent tools context with thinking process if requested
let enhancedContext = context;
if (contextNoteId) {
try {
const agentContext = await this.getAgentToolsContext(
contextNoteId,
userQuestion,
showThinking,
relevantNotes
);
if (agentContext) {
enhancedContext = enhancedContext + "\n\n" + agentContext;
}
} catch (error) {
log.error(`Error getting agent tools context: ${error}`);
// Continue with the basic context
}
}
return {
context: enhancedContext,
notes: relevantNotes,
queries: searchQueries
};
} catch (error) {
log.error(`Error processing query: ${error}`);
return {
context: "I am an AI assistant helping you with your Trilium notes. " +
"I'll try to assist you with general knowledge about your query.",
notes: [],
queries: [userQuestion]
};
}
}
/**
* Get context with agent tools enhancement
*
* @param noteId - The relevant note ID
* @param query - The user's query
* @param showThinking - Whether to show thinking process
* @param relevantNotes - Optional pre-found relevant notes
* @returns Enhanced context string
*/
async getAgentToolsContext(
noteId: string,
query: string,
showThinking: boolean = false,
relevantNotes: Array<any> = []
): Promise<string> {
try {
return await aiServiceManager.getInstance().getAgentToolsContext(
noteId,
query,
showThinking,
relevantNotes
);
} catch (error) {
log.error(`Error getting agent tools context: ${error}`);
return '';
}
}
/**
* Get semantic context for a note and query
*
* @param noteId - The base note ID
* @param userQuery - The user's query
* @param maxResults - Maximum number of results to include
* @returns Formatted context string
*/
async getSemanticContext(noteId: string, userQuery: string, maxResults: number = 5): Promise<string> {
if (!this.initialized) {
await this.initialize();
}
try {
// Get related notes from the context extractor
const [
parentNotes,
childNotes,
linkedNotes
] = await Promise.all([
this.contextExtractor.getParentNotes(noteId, 3),
this.contextExtractor.getChildContext(noteId, 10).then(context => {
// Parse child notes from context string
const lines = context.split('\n');
const result: {noteId: string, title: string}[] = [];
for (const line of lines) {
const match = line.match(/- (.*)/);
if (match) {
// We don't have noteIds in the context string, so use titles only
result.push({
title: match[1],
noteId: '' // Empty noteId since we can't extract it from context
});
}
}
return result;
}),
this.contextExtractor.getLinkedNotesContext(noteId, 10).then(context => {
// Parse linked notes from context string
const lines = context.split('\n');
const result: {noteId: string, title: string}[] = [];
for (const line of lines) {
const match = line.match(/- \[(.*?)\]\(trilium:\/\/([a-zA-Z0-9]+)\)/);
if (match) {
result.push({
title: match[1],
noteId: match[2]
});
}
}
return result;
})
]);
// Combine all related notes
const allRelatedNotes = [...parentNotes, ...childNotes, ...linkedNotes];
// If no related notes, return empty context
if (allRelatedNotes.length === 0) {
return '';
}
// Rank notes by relevance to query
const rankedNotes = await semanticSearch.rankNotesByRelevance(allRelatedNotes, userQuery);
// Get content for the top N most relevant notes
const mostRelevantNotes = rankedNotes.slice(0, maxResults);
const relevantContent = await Promise.all(
mostRelevantNotes.map(async note => {
const content = await this.contextExtractor.getNoteContent(note.noteId);
if (!content) return null;
// Format with relevance score and title
return `### ${note.title} (Relevance: ${Math.round(note.relevance * 100)}%)\n\n${content}`;
})
);
// If no content retrieved, return empty string
if (!relevantContent.filter(Boolean).length) {
return '';
}
return `# Relevant Context\n\nThe following notes are most relevant to your query:\n\n${
relevantContent.filter(Boolean).join('\n\n---\n\n')
}`;
} catch (error) {
log.error(`Error getting semantic context: ${error}`);
return '';
}
}
/**
* Get progressive context loading based on depth
*
* @param noteId - The base note ID
* @param depth - Depth level (1-4)
* @returns Context string with progressively more information
*/
async getProgressiveContext(noteId: string, depth: number = 1): Promise<string> {
if (!this.initialized) {
await this.initialize();
}
try {
// Use the existing context extractor method
return await this.contextExtractor.getProgressiveContext(noteId, depth);
} catch (error) {
log.error(`Error getting progressive context: ${error}`);
return '';
}
}
/**
* Get smart context that adapts to query complexity
*
* @param noteId - The base note ID
* @param userQuery - The user's query
* @returns Context string with appropriate level of detail
*/
async getSmartContext(noteId: string, userQuery: string): Promise<string> {
if (!this.initialized) {
await this.initialize();
}
try {
// Determine query complexity to adjust context depth
const complexity = queryEnhancer.estimateQueryComplexity(userQuery);
// If it's a simple query with low complexity, use progressive context
if (complexity < 0.3) {
return await this.getProgressiveContext(noteId, 2); // Just note + parents
}
// For medium complexity, include more context
else if (complexity < 0.7) {
return await this.getProgressiveContext(noteId, 3); // Note + parents + children
}
// For complex queries, use semantic context
else {
return await this.getSemanticContext(noteId, userQuery, 7); // More results for complex queries
}
} catch (error) {
log.error(`Error getting smart context: ${error}`);
// Fallback to basic context extraction
return await this.contextExtractor.extractContext(noteId);
}
}
/**
* Clear all context caches
*/
clearCaches(): void {
cacheManager.clearAllCaches();
}
}
// Export singleton instance
export default new ContextService();

View File

@ -0,0 +1,99 @@
import options from '../../../options.js';
import log from '../../../log.js';
import { getEmbeddingProvider, getEnabledEmbeddingProviders } from '../../embeddings/providers.js';
/**
* Manages embedding providers for context services
*/
export class ProviderManager {
/**
* Get the preferred embedding provider based on user settings
* Tries to use the most appropriate provider in this order:
* 1. User's configured default provider
* 2. OpenAI if API key is set
* 3. Anthropic if API key is set
* 4. Ollama if configured
* 5. Any available provider
* 6. Local provider as fallback
*
* @returns The preferred embedding provider or null if none available
*/
async getPreferredEmbeddingProvider(): Promise<any> {
try {
// First try user's configured default provider
const providerId = await options.getOption('embeddingsDefaultProvider');
if (providerId) {
const provider = await getEmbeddingProvider(providerId);
if (provider) {
log.info(`Using configured embedding provider: ${providerId}`);
return provider;
}
}
// Then try OpenAI
const openaiKey = await options.getOption('openaiApiKey');
if (openaiKey) {
const provider = await getEmbeddingProvider('openai');
if (provider) {
log.info('Using OpenAI embeddings provider');
return provider;
}
}
// Try Anthropic
const anthropicKey = await options.getOption('anthropicApiKey');
if (anthropicKey) {
const provider = await getEmbeddingProvider('anthropic');
if (provider) {
log.info('Using Anthropic embeddings provider');
return provider;
}
}
// Try Ollama
const provider = await getEmbeddingProvider('ollama');
if (provider) {
log.info('Using Ollama embeddings provider');
return provider;
}
// If no preferred providers, get any enabled provider
const providers = await getEnabledEmbeddingProviders();
if (providers.length > 0) {
log.info(`Using available embedding provider: ${providers[0].name}`);
return providers[0];
}
// Last resort is local provider
log.info('Using local embedding provider as fallback');
return await getEmbeddingProvider('local');
} catch (error) {
log.error(`Error getting preferred embedding provider: ${error}`);
return null;
}
}
/**
* Generate embeddings for a text query
*
* @param query - The text query to embed
* @returns The generated embedding or null if failed
*/
async generateQueryEmbedding(query: string): Promise<Float32Array | null> {
try {
// Get the preferred embedding provider
const provider = await this.getPreferredEmbeddingProvider();
if (!provider) {
log.error('No embedding provider available');
return null;
}
return await provider.generateEmbeddings(query);
} catch (error) {
log.error(`Error generating query embedding: ${error}`);
return null;
}
}
}
// Export singleton instance
export default new ProviderManager();

View File

@ -0,0 +1,168 @@
import log from '../../../log.js';
import cacheManager from './cache_manager.js';
import type { Message } from '../../ai_interface.js';
/**
* Provides utilities for enhancing queries and generating search queries
*/
export class QueryEnhancer {
// Default meta-prompt for query enhancement
private metaPrompt = `You are an AI assistant that decides what information needs to be retrieved from a user's knowledge base called TriliumNext Notes to answer the user's question.
Given the user's question, generate 3-5 specific search queries that would help find relevant information.
Each query should be focused on a different aspect of the question.
Format your answer as a JSON array of strings, with each string being a search query.
Example: ["exact topic mentioned", "related concept 1", "related concept 2"]`;
/**
* Generate search queries to find relevant information for the user question
*
* @param userQuestion - The user's question
* @param llmService - The LLM service to use for generating queries
* @returns Array of search queries
*/
async generateSearchQueries(userQuestion: string, llmService: any): Promise<string[]> {
try {
// Check cache first
const cached = cacheManager.getQueryResults(`searchQueries:${userQuestion}`);
if (cached) {
return cached;
}
const messages: Message[] = [
{ role: "system", content: this.metaPrompt },
{ role: "user", content: userQuestion }
];
const options = {
temperature: 0.3,
maxTokens: 300
};
// Get the response from the LLM
const response = await llmService.generateChatCompletion(messages, options);
const responseText = response.text; // Extract the text from the response object
try {
// Remove code blocks, quotes, and clean up the response text
let jsonStr = responseText
.replace(/```(?:json)?|```/g, '') // Remove code block markers
.replace(/[\u201C\u201D]/g, '"') // Replace smart quotes with straight quotes
.trim();
// Check if the text might contain a JSON array (has square brackets)
if (jsonStr.includes('[') && jsonStr.includes(']')) {
// Extract just the array part if there's explanatory text
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
if (arrayMatch) {
jsonStr = arrayMatch[0];
}
// Try to parse the JSON
try {
const queries = JSON.parse(jsonStr);
if (Array.isArray(queries) && queries.length > 0) {
const result = queries.map(q => typeof q === 'string' ? q : String(q)).filter(Boolean);
cacheManager.storeQueryResults(`searchQueries:${userQuestion}`, result);
return result;
}
} catch (innerError) {
// If parsing fails, log it and continue to the fallback
log.info(`JSON parse error: ${innerError}. Will use fallback parsing for: ${jsonStr}`);
}
}
// Fallback 1: Try to extract an array manually by splitting on commas between quotes
if (jsonStr.includes('[') && jsonStr.includes(']')) {
const arrayContent = jsonStr.substring(
jsonStr.indexOf('[') + 1,
jsonStr.lastIndexOf(']')
);
// Use regex to match quoted strings, handling escaped quotes
const stringMatches = arrayContent.match(/"((?:\\.|[^"\\])*)"/g);
if (stringMatches && stringMatches.length > 0) {
const result = stringMatches
.map((m: string) => m.substring(1, m.length - 1)) // Remove surrounding quotes
.filter((s: string) => s.length > 0);
cacheManager.storeQueryResults(`searchQueries:${userQuestion}`, result);
return result;
}
}
// Fallback 2: Extract queries line by line
const lines = responseText.split('\n')
.map((line: string) => line.trim())
.filter((line: string) =>
line.length > 0 &&
!line.startsWith('```') &&
!line.match(/^\d+\.?\s*$/) && // Skip numbered list markers alone
!line.match(/^\[|\]$/) // Skip lines that are just brackets
);
if (lines.length > 0) {
// Remove numbering, quotes and other list markers from each line
const result = lines.map((line: string) => {
return line
.replace(/^\d+\.?\s*/, '') // Remove numbered list markers (1., 2., etc)
.replace(/^[-*•]\s*/, '') // Remove bullet list markers
.replace(/^["']|["']$/g, '') // Remove surrounding quotes
.trim();
}).filter((s: string) => s.length > 0);
cacheManager.storeQueryResults(`searchQueries:${userQuestion}`, result);
return result;
}
} catch (parseError) {
log.error(`Error parsing search queries: ${parseError}`);
}
// If all else fails, just use the original question
const fallback = [userQuestion];
cacheManager.storeQueryResults(`searchQueries:${userQuestion}`, fallback);
return fallback;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Error generating search queries: ${errorMessage}`);
// Fallback to just using the original question
return [userQuestion];
}
}
/**
* Estimate the complexity of a query
* This is used to determine the appropriate amount of context to provide
*
* @param query - The query to analyze
* @returns A complexity score from 0 (simple) to 1 (complex)
*/
estimateQueryComplexity(query: string): number {
// Simple complexity estimation based on various factors
// Factor 1: Query length
const lengthScore = Math.min(query.length / 100, 0.4);
// Factor 2: Number of question words
const questionWords = ['what', 'how', 'why', 'when', 'where', 'who', 'which'];
const questionWordsCount = questionWords.filter(word =>
query.toLowerCase().includes(` ${word} `) ||
query.toLowerCase().startsWith(`${word} `)
).length;
const questionWordsScore = Math.min(questionWordsCount * 0.15, 0.3);
// Factor 3: Contains comparison indicators
const comparisonWords = ['compare', 'difference', 'versus', 'vs', 'similarities', 'differences'];
const hasComparison = comparisonWords.some(word => query.toLowerCase().includes(word));
const comparisonScore = hasComparison ? 0.2 : 0;
// Factor 4: Request for detailed or in-depth information
const depthWords = ['explain', 'detail', 'elaborate', 'analysis', 'in-depth'];
const hasDepthRequest = depthWords.some(word => query.toLowerCase().includes(word));
const depthScore = hasDepthRequest ? 0.2 : 0;
// Combine scores with a maximum of 1.0
return Math.min(lengthScore + questionWordsScore + comparisonScore + depthScore, 1.0);
}
}
// Export singleton instance
export default new QueryEnhancer();

View File

@ -0,0 +1,306 @@
import * as vectorStore from '../../embeddings/index.js';
import { cosineSimilarity } from '../../embeddings/index.js';
import log from '../../../log.js';
import becca from '../../../../becca/becca.js';
import providerManager from './provider_manager.js';
import cacheManager from './cache_manager.js';
import { ContextExtractor } from '../index.js';
/**
* Provides semantic search capabilities for finding relevant notes
*/
export class SemanticSearch {
private contextExtractor: ContextExtractor;
constructor() {
this.contextExtractor = new ContextExtractor();
}
/**
* Rank notes by their semantic relevance to a query
*
* @param notes - Array of notes with noteId and title
* @param userQuery - The user's query to compare against
* @returns Sorted array of notes with relevance score
*/
async rankNotesByRelevance(
notes: Array<{noteId: string, title: string}>,
userQuery: string
): Promise<Array<{noteId: string, title: string, relevance: number}>> {
// Try to get from cache first
const cacheKey = `rank:${userQuery}:${notes.map(n => n.noteId).join(',')}`;
const cached = cacheManager.getNoteData('', cacheKey);
if (cached) {
return cached;
}
const queryEmbedding = await providerManager.generateQueryEmbedding(userQuery);
if (!queryEmbedding) {
// If embedding fails, return notes in original order
return notes.map(note => ({ ...note, relevance: 0 }));
}
const provider = await providerManager.getPreferredEmbeddingProvider();
if (!provider) {
return notes.map(note => ({ ...note, relevance: 0 }));
}
const rankedNotes = [];
for (const note of notes) {
// Get note embedding from vector store or generate it if not exists
let noteEmbedding = null;
try {
const embeddingResult = await vectorStore.getEmbeddingForNote(
note.noteId,
provider.name,
provider.getConfig().model || ''
);
if (embeddingResult) {
noteEmbedding = embeddingResult.embedding;
}
} catch (error) {
log.error(`Error retrieving embedding for note ${note.noteId}: ${error}`);
}
if (!noteEmbedding) {
// If note doesn't have an embedding yet, get content and generate one
const content = await this.contextExtractor.getNoteContent(note.noteId);
if (content && provider) {
try {
noteEmbedding = await provider.generateEmbeddings(content);
// Store the embedding for future use
await vectorStore.storeNoteEmbedding(
note.noteId,
provider.name,
provider.getConfig().model || '',
noteEmbedding
);
} catch (error) {
log.error(`Error generating embedding for note ${note.noteId}: ${error}`);
}
}
}
let relevance = 0;
if (noteEmbedding) {
// Calculate cosine similarity between query and note
relevance = cosineSimilarity(queryEmbedding, noteEmbedding);
}
rankedNotes.push({
...note,
relevance
});
}
// Sort by relevance (highest first)
const result = rankedNotes.sort((a, b) => b.relevance - a.relevance);
// Cache results
cacheManager.storeNoteData('', cacheKey, result);
return result;
}
/**
* Find notes that are semantically relevant to a query
*
* @param query - The search query
* @param contextNoteId - Optional note ID to restrict search to a branch
* @param limit - Maximum number of results to return
* @returns Array of relevant notes with similarity scores
*/
async findRelevantNotes(
query: string,
contextNoteId: string | null = null,
limit = 10
): Promise<{noteId: string, title: string, content: string | null, similarity: number}[]> {
try {
// Check cache first
const cacheKey = `find:${query}:${contextNoteId || 'all'}:${limit}`;
const cached = cacheManager.getQueryResults(cacheKey);
if (cached) {
return cached;
}
// Get embedding for query
const queryEmbedding = await providerManager.generateQueryEmbedding(query);
if (!queryEmbedding) {
log.error('Failed to generate query embedding');
return [];
}
let results: {noteId: string, similarity: number}[] = [];
// Get provider information
const provider = await providerManager.getPreferredEmbeddingProvider();
if (!provider) {
log.error('No embedding provider available');
return [];
}
// If contextNoteId is provided, search only within that branch
if (contextNoteId) {
results = await this.findNotesInBranch(queryEmbedding, contextNoteId, limit);
} else {
// Otherwise search across all notes with embeddings
results = await vectorStore.findSimilarNotes(
queryEmbedding,
provider.name,
provider.getConfig().model || '',
limit
);
}
// Get note details for results
const enrichedResults = await Promise.all(
results.map(async result => {
const note = becca.getNote(result.noteId);
if (!note) {
return null;
}
// Get note content
const content = await this.contextExtractor.getNoteContent(result.noteId);
return {
noteId: result.noteId,
title: note.title,
content,
similarity: result.similarity
};
})
);
// Filter out null results
const filteredResults = enrichedResults.filter(Boolean) as {
noteId: string,
title: string,
content: string | null,
similarity: number
}[];
// Cache results
cacheManager.storeQueryResults(cacheKey, filteredResults);
return filteredResults;
} catch (error) {
log.error(`Error finding relevant notes: ${error}`);
return [];
}
}
/**
* Find notes in a specific branch (subtree) that are relevant to a query
*
* @param embedding - The query embedding
* @param contextNoteId - Root note ID of the branch
* @param limit - Maximum results to return
* @returns Array of note IDs with similarity scores
*/
private async findNotesInBranch(
embedding: Float32Array,
contextNoteId: string,
limit = 5
): Promise<{noteId: string, similarity: number}[]> {
try {
// Get all notes in the subtree
const noteIds = await this.getSubtreeNoteIds(contextNoteId);
if (noteIds.length === 0) {
return [];
}
// Get provider information
const provider = await providerManager.getPreferredEmbeddingProvider();
if (!provider) {
log.error('No embedding provider available');
return [];
}
// Get model configuration
const model = provider.getConfig().model || '';
const providerName = provider.name;
// Check if vectorStore has the findSimilarNotesInSet method
if (typeof vectorStore.findSimilarNotesInSet === 'function') {
// Use the dedicated method if available
return await vectorStore.findSimilarNotesInSet(
embedding,
noteIds,
providerName,
model,
limit
);
}
// Fallback: Manually search through the notes in the subtree
const similarities: {noteId: string, similarity: number}[] = [];
for (const noteId of noteIds) {
try {
const noteEmbedding = await vectorStore.getEmbeddingForNote(
noteId,
providerName,
model
);
if (noteEmbedding && noteEmbedding.embedding) {
const similarity = cosineSimilarity(embedding, noteEmbedding.embedding);
if (similarity > 0.5) { // Apply a similarity threshold
similarities.push({
noteId,
similarity
});
}
}
} catch (error) {
// Skip notes that don't have embeddings
continue;
}
}
// Sort by similarity and return top results
return similarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
} catch (error) {
log.error(`Error finding notes in branch: ${error}`);
return [];
}
}
/**
* Get all note IDs in a subtree
*
* @param rootNoteId - The root note ID
* @returns Array of note IDs in the subtree
*/
private async getSubtreeNoteIds(rootNoteId: string): Promise<string[]> {
const noteIds = new Set<string>();
noteIds.add(rootNoteId); // Include the root note itself
const collectChildNotes = (noteId: string) => {
const note = becca.getNote(noteId);
if (!note) {
return;
}
const childNotes = note.getChildNotes();
for (const childNote of childNotes) {
if (!noteIds.has(childNote.noteId)) {
noteIds.add(childNote.noteId);
collectChildNotes(childNote.noteId);
}
}
};
collectChildNotes(rootNoteId);
return Array.from(noteIds);
}
}
// Export singleton instance
export default new SemanticSearch();

View File

@ -0,0 +1,190 @@
/**
* Trilium Notes Context Service
*
* Unified entry point for all context-related services
* Provides intelligent context management for AI features
*/
import log from '../log.js';
import contextService from './context/modules/context_service.js';
import { ContextExtractor } from './context/index.js';
/**
* Main Context Service for Trilium Notes
*
* This service provides a unified interface for all context-related functionality:
* - Processing user queries with semantic search
* - Finding relevant notes using AI-enhanced query understanding
* - Progressive context loading based on query complexity
* - Semantic context extraction
* - Context formatting for different LLM providers
*
* This implementation uses a modular approach with specialized services:
* - Provider management
* - Cache management
* - Semantic search
* - Query enhancement
* - Context formatting
*/
class TriliumContextService {
private contextExtractor: ContextExtractor;
constructor() {
this.contextExtractor = new ContextExtractor();
log.info('TriliumContextService created');
}
/**
* Initialize the context service
*/
async initialize(): Promise<void> {
return contextService.initialize();
}
/**
* Process a user query to find relevant context in Trilium notes
*
* @param userQuestion - The user's query
* @param llmService - The LLM service to use for query enhancement
* @param contextNoteId - Optional note ID to restrict search to a branch
* @param showThinking - Whether to show the LLM's thinking process
* @returns Context information and relevant notes
*/
async processQuery(
userQuestion: string,
llmService: any,
contextNoteId: string | null = null,
showThinking: boolean = false
) {
return contextService.processQuery(userQuestion, llmService, contextNoteId, showThinking);
}
/**
* Get context enhanced with agent tools
*
* @param noteId - The current note ID
* @param query - The user's query
* @param showThinking - Whether to show thinking process
* @param relevantNotes - Optional pre-found relevant notes
* @returns Enhanced context string
*/
async getAgentToolsContext(
noteId: string,
query: string,
showThinking: boolean = false,
relevantNotes: Array<any> = []
): Promise<string> {
return contextService.getAgentToolsContext(noteId, query, showThinking, relevantNotes);
}
/**
* Build formatted context from notes
*
* @param sources - Array of notes or content sources
* @param query - The original user query
* @returns Formatted context string
*/
async buildContextFromNotes(sources: any[], query: string): Promise<string> {
const provider = await (await import('./context/modules/provider_manager.js')).default.getPreferredEmbeddingProvider();
const providerId = provider?.name || 'default';
return (await import('./context/modules/context_formatter.js')).default.buildContextFromNotes(sources, query, providerId);
}
/**
* Find relevant notes using multi-query approach
*
* @param queries - Array of search queries
* @param contextNoteId - Optional note ID to restrict search
* @param limit - Maximum notes to return
* @returns Array of relevant notes
*/
async findRelevantNotesMultiQuery(
queries: string[],
contextNoteId: string | null = null,
limit = 10
): Promise<any[]> {
const allResults: Map<string, any> = new Map();
for (const query of queries) {
const results = await (await import('./context/modules/semantic_search.js')).default.findRelevantNotes(
query,
contextNoteId,
Math.ceil(limit / queries.length) // Distribute limit among queries
);
// Combine results, avoiding duplicates
for (const result of results) {
if (!allResults.has(result.noteId)) {
allResults.set(result.noteId, result);
} else {
// If note already exists, update similarity to max of both values
const existing = allResults.get(result.noteId);
if (result.similarity > existing.similarity) {
existing.similarity = result.similarity;
allResults.set(result.noteId, existing);
}
}
}
}
// Convert map to array and limit to top results
return Array.from(allResults.values())
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
}
/**
* Generate search queries to find relevant information
*
* @param userQuestion - The user's question
* @param llmService - The LLM service to use for generating queries
* @returns Array of search queries
*/
async generateSearchQueries(userQuestion: string, llmService: any): Promise<string[]> {
return (await import('./context/modules/query_enhancer.js')).default.generateSearchQueries(userQuestion, llmService);
}
/**
* Get semantic context for a note
*
* @param noteId - The note ID
* @param userQuery - The user's query
* @param maxResults - Maximum results to include
* @returns Formatted context string
*/
async getSemanticContext(noteId: string, userQuery: string, maxResults = 5): Promise<string> {
return contextService.getSemanticContext(noteId, userQuery, maxResults);
}
/**
* Get progressive context based on depth level
*
* @param noteId - The note ID
* @param depth - Depth level (1-4)
* @returns Context string
*/
async getProgressiveContext(noteId: string, depth = 1): Promise<string> {
return contextService.getProgressiveContext(noteId, depth);
}
/**
* Get smart context that adapts to query complexity
*
* @param noteId - The note ID
* @param userQuery - The user's query
* @returns Context string
*/
async getSmartContext(noteId: string, userQuery: string): Promise<string> {
return contextService.getSmartContext(noteId, userQuery);
}
/**
* Clear all context caches
*/
clearCaches(): void {
return contextService.clearCaches();
}
}
// Export singleton instance
export default new TriliumContextService();

View File

@ -1,404 +0,0 @@
import { ContextExtractor } from './context/index.js';
import * as vectorStore from './embeddings/index.js';
import sql from '../sql.js';
import { cosineSimilarity } from './embeddings/index.js';
import log from '../log.js';
import { getEmbeddingProvider, getEnabledEmbeddingProviders } from './embeddings/providers.js';
import options from '../options.js';
/**
* SEMANTIC CONTEXT SERVICE
*
* This service provides advanced context extraction capabilities for AI models.
* It enhances the basic context extractor with vector embedding-based semantic
* search and progressive context loading for large notes.
*
* === USAGE GUIDE ===
*
* 1. To use this service in other modules:
* ```
* import aiServiceManager from './services/llm/ai_service_manager.js';
* const semanticContext = aiServiceManager.getSemanticContextService();
* ```
*
* Or with the instance directly:
* ```
* import aiServiceManager from './services/llm/ai_service_manager.js';
* const semanticContext = aiServiceManager.getInstance().getSemanticContextService();
* ```
*
* 2. Retrieve context based on semantic relevance to a query:
* ```
* const context = await semanticContext.getSemanticContext(noteId, userQuery);
* ```
*
* 3. Load context progressively (only what's needed):
* ```
* const context = await semanticContext.getProgressiveContext(noteId, depth);
* // depth: 1=just note, 2=+parents, 3=+children, 4=+linked notes
* ```
*
* 4. Use smart context selection that adapts to query complexity:
* ```
* const context = await semanticContext.getSmartContext(noteId, userQuery);
* ```
*
* === REQUIREMENTS ===
*
* - Requires at least one configured embedding provider (OpenAI, Anthropic, Ollama)
* - Will fall back to non-semantic methods if no embedding provider is available
* - Uses OpenAI embeddings by default if API key is configured
*/
/**
* Provides advanced semantic context capabilities, enhancing the basic context extractor
* with vector embedding-based semantic search and progressive context loading.
*
* This service is especially useful for retrieving the most relevant context from large
* knowledge bases when working with limited-context LLMs.
*/
class SemanticContextService {
// Create an instance of ContextExtractor for backward compatibility
private contextExtractor = new ContextExtractor();
/**
* Get the preferred embedding provider based on user settings
* Tries to use the most appropriate provider in this order:
* 1. OpenAI if API key is set
* 2. Anthropic if API key is set
* 3. Ollama if configured
* 4. Any available provider
* 5. Local provider as fallback
*
* @returns The preferred embedding provider or null if none available
*/
private async getPreferredEmbeddingProvider(): Promise<any> {
// Try to get provider in order of preference
const openaiKey = await options.getOption('openaiApiKey');
if (openaiKey) {
const provider = await getEmbeddingProvider('openai');
if (provider) return provider;
}
const anthropicKey = await options.getOption('anthropicApiKey');
if (anthropicKey) {
const provider = await getEmbeddingProvider('anthropic');
if (provider) return provider;
}
// If neither of the preferred providers is available, get any provider
const providers = await getEnabledEmbeddingProviders();
if (providers.length > 0) {
return providers[0];
}
// Last resort is local provider
return await getEmbeddingProvider('local');
}
/**
* Generate embeddings for a text query
*
* @param query - The text query to embed
* @returns The generated embedding or null if failed
*/
private async generateQueryEmbedding(query: string): Promise<Float32Array | null> {
try {
// Get the preferred embedding provider
const provider = await this.getPreferredEmbeddingProvider();
if (!provider) {
return null;
}
return await provider.generateEmbeddings(query);
} catch (error) {
log.error(`Error generating query embedding: ${error}`);
return null;
}
}
/**
* Rank notes by semantic relevance to a query using vector similarity
*
* @param notes - Array of notes with noteId and title
* @param userQuery - The user's query to compare against
* @returns Sorted array of notes with relevance score
*/
async rankNotesByRelevance(
notes: Array<{noteId: string, title: string}>,
userQuery: string
): Promise<Array<{noteId: string, title: string, relevance: number}>> {
const queryEmbedding = await this.generateQueryEmbedding(userQuery);
if (!queryEmbedding) {
// If embedding fails, return notes in original order
return notes.map(note => ({ ...note, relevance: 0 }));
}
const provider = await this.getPreferredEmbeddingProvider();
if (!provider) {
return notes.map(note => ({ ...note, relevance: 0 }));
}
const rankedNotes = [];
for (const note of notes) {
// Get note embedding from vector store or generate it if not exists
let noteEmbedding = null;
try {
const embeddingResult = await vectorStore.getEmbeddingForNote(
note.noteId,
provider.name,
provider.getConfig().model || ''
);
if (embeddingResult) {
noteEmbedding = embeddingResult.embedding;
}
} catch (error) {
log.error(`Error retrieving embedding for note ${note.noteId}: ${error}`);
}
if (!noteEmbedding) {
// If note doesn't have an embedding yet, get content and generate one
const content = await this.contextExtractor.getNoteContent(note.noteId);
if (content && provider) {
try {
noteEmbedding = await provider.generateEmbeddings(content);
// Store the embedding for future use
await vectorStore.storeNoteEmbedding(
note.noteId,
provider.name,
provider.getConfig().model || '',
noteEmbedding
);
} catch (error) {
log.error(`Error generating embedding for note ${note.noteId}: ${error}`);
}
}
}
let relevance = 0;
if (noteEmbedding) {
// Calculate cosine similarity between query and note
relevance = cosineSimilarity(queryEmbedding, noteEmbedding);
}
rankedNotes.push({
...note,
relevance
});
}
// Sort by relevance (highest first)
return rankedNotes.sort((a, b) => b.relevance - a.relevance);
}
/**
* Retrieve semantic context based on relevance to user query
* Finds the most semantically similar notes to the user's query
*
* @param noteId - Base note ID to start the search from
* @param userQuery - Query to find relevant context for
* @param maxResults - Maximum number of notes to include in context
* @returns Formatted context with the most relevant notes
*/
async getSemanticContext(noteId: string, userQuery: string, maxResults = 5): Promise<string> {
// Get related notes (parents, children, linked notes)
const [
parentNotes,
childNotes,
linkedNotes
] = await Promise.all([
this.getParentNotes(noteId, 3),
this.getChildNotes(noteId, 10),
this.getLinkedNotes(noteId, 10)
]);
// Combine all related notes
const allRelatedNotes = [...parentNotes, ...childNotes, ...linkedNotes];
// If no related notes, return empty context
if (allRelatedNotes.length === 0) {
return '';
}
// Rank notes by relevance to query
const rankedNotes = await this.rankNotesByRelevance(allRelatedNotes, userQuery);
// Get content for the top N most relevant notes
const mostRelevantNotes = rankedNotes.slice(0, maxResults);
const relevantContent = await Promise.all(
mostRelevantNotes.map(async note => {
const content = await this.contextExtractor.getNoteContent(note.noteId);
if (!content) return null;
// Format with relevance score and title
return `### ${note.title} (Relevance: ${Math.round(note.relevance * 100)}%)\n\n${content}`;
})
);
// If no content retrieved, return empty string
if (!relevantContent.filter(Boolean).length) {
return '';
}
return `# Relevant Context\n\nThe following notes are most relevant to your query:\n\n${
relevantContent.filter(Boolean).join('\n\n---\n\n')
}`;
}
/**
* Load context progressively based on depth level
* This allows starting with minimal context and expanding as needed
*
* @param noteId - The ID of the note to get context for
* @param depth - Depth level (1-4) determining how much context to include
* @returns Context appropriate for the requested depth
*/
async getProgressiveContext(noteId: string, depth = 1): Promise<string> {
// Start with the note content
const noteContent = await this.contextExtractor.getNoteContent(noteId);
if (!noteContent) return 'Note not found';
// If depth is 1, just return the note content
if (depth <= 1) return noteContent;
// Add parent context for depth >= 2
const parentContext = await this.contextExtractor.getParentContext(noteId);
if (depth <= 2) return `${parentContext}\n\n${noteContent}`;
// Add child context for depth >= 3
const childContext = await this.contextExtractor.getChildContext(noteId);
if (depth <= 3) return `${parentContext}\n\n${noteContent}\n\n${childContext}`;
// Add linked notes for depth >= 4
const linkedContext = await this.contextExtractor.getLinkedNotesContext(noteId);
return `${parentContext}\n\n${noteContent}\n\n${childContext}\n\n${linkedContext}`;
}
/**
* Get parent notes in the hierarchy
* Helper method that queries the database directly
*/
private async getParentNotes(noteId: string, maxDepth: number): Promise<{noteId: string, title: string}[]> {
const parentNotes: {noteId: string, title: string}[] = [];
let currentNoteId = noteId;
for (let i = 0; i < maxDepth; i++) {
const parent = await sql.getRow<{parentNoteId: string, title: string}>(
`SELECT branches.parentNoteId, notes.title
FROM branches
JOIN notes ON branches.parentNoteId = notes.noteId
WHERE branches.noteId = ? AND branches.isDeleted = 0 LIMIT 1`,
[currentNoteId]
);
if (!parent || parent.parentNoteId === 'root') {
break;
}
parentNotes.unshift({
noteId: parent.parentNoteId,
title: parent.title
});
currentNoteId = parent.parentNoteId;
}
return parentNotes;
}
/**
* Get child notes
* Helper method that queries the database directly
*/
private async getChildNotes(noteId: string, maxChildren: number): Promise<{noteId: string, title: string}[]> {
return await sql.getRows<{noteId: string, title: string}>(
`SELECT noteId, title FROM notes
WHERE parentNoteId = ? AND isDeleted = 0
LIMIT ?`,
[noteId, maxChildren]
);
}
/**
* Get linked notes
* Helper method that queries the database directly
*/
private async getLinkedNotes(noteId: string, maxLinks: number): Promise<{noteId: string, title: string}[]> {
return await sql.getRows<{noteId: string, title: string}>(
`SELECT noteId, title FROM notes
WHERE noteId IN (
SELECT value FROM attributes
WHERE noteId = ? AND type = 'relation'
LIMIT ?
)`,
[noteId, maxLinks]
);
}
/**
* Smart context selection that combines semantic matching with progressive loading
* Returns the most appropriate context based on the query and available information
*
* @param noteId - The ID of the note to get context for
* @param userQuery - The user's query for semantic relevance matching
* @returns The optimal context for answering the query
*/
async getSmartContext(noteId: string, userQuery: string): Promise<string> {
// Check if embedding provider is available
const provider = await this.getPreferredEmbeddingProvider();
if (provider) {
try {
const semanticContext = await this.getSemanticContext(noteId, userQuery);
if (semanticContext) {
return semanticContext;
}
} catch (error) {
log.error(`Error getting semantic context: ${error}`);
// Fall back to progressive context if semantic fails
}
}
// Default to progressive context with appropriate depth based on query complexity
// Simple queries get less context, complex ones get more
const queryComplexity = this.estimateQueryComplexity(userQuery);
const depth = Math.min(4, Math.max(1, queryComplexity));
return this.getProgressiveContext(noteId, depth);
}
/**
* Estimate query complexity to determine appropriate context depth
*
* @param query - The user's query string
* @returns Complexity score from 1-4
*/
private estimateQueryComplexity(query: string): number {
if (!query) return 1;
// Simple heuristics for query complexity:
// 1. Length (longer queries tend to be more complex)
// 2. Number of questions or specific requests
// 3. Presence of complex terms/concepts
const words = query.split(/\s+/).length;
const questions = (query.match(/\?/g) || []).length;
const comparisons = (query.match(/compare|difference|versus|vs\.|between/gi) || []).length;
const complexity = (query.match(/explain|analyze|synthesize|evaluate|critique|recommend|suggest/gi) || []).length;
// Calculate complexity score
let score = 1;
if (words > 20) score += 1;
if (questions > 1) score += 1;
if (comparisons > 0) score += 1;
if (complexity > 0) score += 1;
return Math.min(4, score);
}
}
// Singleton instance
const semanticContextService = new SemanticContextService();
export default semanticContextService;

View File

@ -1,870 +0,0 @@
import becca from "../../becca/becca.js";
import vectorStore from "./embeddings/index.js";
import providerManager from "./embeddings/providers.js";
import options from "../options.js";
import log from "../log.js";
import type { Message } from "./ai_interface.js";
import { cosineSimilarity } from "./embeddings/index.js";
import sanitizeHtml from "sanitize-html";
import aiServiceManager from "./ai_service_manager.js";
/**
* TriliumContextService provides intelligent context management for working with large knowledge bases
* through limited context window LLMs like Ollama.
*
* It creates a "meta-prompting" approach where the first LLM call is used
* to determine what information might be needed to answer the query,
* then only the relevant context is loaded, before making the final
* response.
*/
class TriliumContextService {
private initialized = false;
private initPromise: Promise<void> | null = null;
private provider: any = null;
// Cache for recently used context to avoid repeated embedding lookups
private recentQueriesCache = new Map<string, {
timestamp: number,
relevantNotes: any[]
}>();
// Configuration
private cacheExpiryMs = 5 * 60 * 1000; // 5 minutes
private metaPrompt = `You are an AI assistant that decides what information needs to be retrieved from a user's knowledge base called TriliumNext Notes to answer the user's question.
Given the user's question, generate 3-5 specific search queries that would help find relevant information.
Each query should be focused on a different aspect of the question.
Format your answer as a JSON array of strings, with each string being a search query.
Example: ["exact topic mentioned", "related concept 1", "related concept 2"]`;
constructor() {
this.setupCacheCleanup();
}
/**
* Initialize the service
*/
async initialize() {
if (this.initialized) return;
// Use a promise to prevent multiple simultaneous initializations
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
try {
// Get user's configured provider or fallback to ollama
const providerId = await options.getOption('embeddingsDefaultProvider') || 'ollama';
this.provider = providerManager.getEmbeddingProvider(providerId);
// If specified provider not found, try ollama as first fallback for self-hosted usage
if (!this.provider && providerId !== 'ollama') {
log.info(`Embedding provider ${providerId} not found, trying ollama as fallback`);
this.provider = providerManager.getEmbeddingProvider('ollama');
}
// If ollama not found, try openai as a second fallback
if (!this.provider && providerId !== 'openai') {
log.info(`Embedding provider ollama not found, trying openai as fallback`);
this.provider = providerManager.getEmbeddingProvider('openai');
}
// Final fallback to local provider which should always exist
if (!this.provider) {
log.info(`No embedding provider found, falling back to local provider`);
this.provider = providerManager.getEmbeddingProvider('local');
}
if (!this.provider) {
throw new Error(`No embedding provider available. Could not initialize context service.`);
}
// Initialize agent tools to ensure they're ready
try {
await aiServiceManager.getInstance().initializeAgentTools();
log.info("Agent tools initialized for use with TriliumContextService");
} catch (toolError) {
log.error(`Error initializing agent tools: ${toolError}`);
// Continue even if agent tools fail to initialize
}
this.initialized = true;
log.info(`Trilium context service initialized with provider: ${this.provider.name}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Failed to initialize Trilium context service: ${errorMessage}`);
throw error;
} finally {
this.initPromise = null;
}
})();
return this.initPromise;
}
/**
* Set up periodic cache cleanup
*/
private setupCacheCleanup() {
setInterval(() => {
const now = Date.now();
for (const [key, data] of this.recentQueriesCache.entries()) {
if (now - data.timestamp > this.cacheExpiryMs) {
this.recentQueriesCache.delete(key);
}
}
}, 60000); // Run cleanup every minute
}
/**
* Generate search queries to find relevant information for the user question
* @param userQuestion - The user's question
* @param llmService - The LLM service to use for generating queries
* @returns Array of search queries
*/
async generateSearchQueries(userQuestion: string, llmService: any): Promise<string[]> {
try {
const messages: Message[] = [
{ role: "system", content: this.metaPrompt },
{ role: "user", content: userQuestion }
];
const options = {
temperature: 0.3,
maxTokens: 300
};
// Get the response from the LLM using the correct method name
const response = await llmService.generateChatCompletion(messages, options);
const responseText = response.text; // Extract the text from the response object
try {
// Remove code blocks, quotes, and clean up the response text
let jsonStr = responseText
.replace(/```(?:json)?|```/g, '') // Remove code block markers
.replace(/[\u201C\u201D]/g, '"') // Replace smart quotes with straight quotes
.trim();
// Check if the text might contain a JSON array (has square brackets)
if (jsonStr.includes('[') && jsonStr.includes(']')) {
// Extract just the array part if there's explanatory text
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
if (arrayMatch) {
jsonStr = arrayMatch[0];
}
// Try to parse the JSON
try {
const queries = JSON.parse(jsonStr);
if (Array.isArray(queries) && queries.length > 0) {
return queries.map(q => typeof q === 'string' ? q : String(q)).filter(Boolean);
}
} catch (innerError) {
// If parsing fails, log it and continue to the fallback
log.info(`JSON parse error: ${innerError}. Will use fallback parsing for: ${jsonStr}`);
}
}
// Fallback 1: Try to extract an array manually by splitting on commas between quotes
if (jsonStr.includes('[') && jsonStr.includes(']')) {
const arrayContent = jsonStr.substring(
jsonStr.indexOf('[') + 1,
jsonStr.lastIndexOf(']')
);
// Use regex to match quoted strings, handling escaped quotes
const stringMatches = arrayContent.match(/"((?:\\.|[^"\\])*)"/g);
if (stringMatches && stringMatches.length > 0) {
return stringMatches
.map((m: string) => m.substring(1, m.length - 1)) // Remove surrounding quotes
.filter((s: string) => s.length > 0);
}
}
// Fallback 2: Extract queries line by line
const lines = responseText.split('\n')
.map((line: string) => line.trim())
.filter((line: string) =>
line.length > 0 &&
!line.startsWith('```') &&
!line.match(/^\d+\.?\s*$/) && // Skip numbered list markers alone
!line.match(/^\[|\]$/) // Skip lines that are just brackets
);
if (lines.length > 0) {
// Remove numbering, quotes and other list markers from each line
return lines.map((line: string) => {
return line
.replace(/^\d+\.?\s*/, '') // Remove numbered list markers (1., 2., etc)
.replace(/^[-*•]\s*/, '') // Remove bullet list markers
.replace(/^["']|["']$/g, '') // Remove surrounding quotes
.trim();
}).filter((s: string) => s.length > 0);
}
} catch (parseError) {
log.error(`Error parsing search queries: ${parseError}`);
}
// If all else fails, just use the original question
return [userQuestion];
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Error generating search queries: ${errorMessage}`);
// Fallback to just using the original question
return [userQuestion];
}
}
/**
* Find relevant notes using multiple search queries
* @param queries - Array of search queries
* @param contextNoteId - Optional note ID to restrict search to a branch
* @param limit - Max notes to return
* @returns Array of relevant notes
*/
async findRelevantNotesMultiQuery(
queries: string[],
contextNoteId: string | null = null,
limit = 10
): Promise<any[]> {
if (!this.initialized) {
await this.initialize();
}
try {
// Cache key combining all queries
const cacheKey = JSON.stringify({ queries, contextNoteId, limit });
// Check if we have a recent cache hit
const cached = this.recentQueriesCache.get(cacheKey);
if (cached) {
return cached.relevantNotes;
}
// Array to store all results with their similarity scores
const allResults: {
noteId: string,
title: string,
content: string | null,
similarity: number,
branchId?: string
}[] = [];
// Set to keep track of note IDs we've seen to avoid duplicates
const seenNoteIds = new Set<string>();
// Log the provider and model being used
log.info(`Searching with embedding provider: ${this.provider.name}, model: ${this.provider.getConfig().model}`);
// Process each query
for (const query of queries) {
// Get embeddings for this query using the correct method name
const queryEmbedding = await this.provider.generateEmbeddings(query);
log.info(`Generated embedding for query: "${query}" (${queryEmbedding.length} dimensions)`);
// Find notes similar to this query
let results;
if (contextNoteId) {
// Find within a specific context/branch
results = await this.findNotesInBranch(
queryEmbedding,
contextNoteId,
Math.min(limit, 5) // Limit per query
);
log.info(`Found ${results.length} notes within branch context for query: "${query}"`);
} else {
// Search all notes
results = await vectorStore.findSimilarNotes(
queryEmbedding,
this.provider.name, // Use name property instead of id
this.provider.getConfig().model, // Use getConfig().model instead of modelId
Math.min(limit, 5), // Limit per query
0.5 // Lower threshold to get more diverse results
);
log.info(`Found ${results.length} notes in vector store for query: "${query}"`);
}
// Process results
for (const result of results) {
if (!seenNoteIds.has(result.noteId)) {
seenNoteIds.add(result.noteId);
// Get the note from Becca
const note = becca.notes[result.noteId];
if (!note) continue;
// Add to our results
allResults.push({
noteId: result.noteId,
title: note.title,
content: note.type === 'text' ? note.getContent() as string : null,
similarity: result.similarity,
branchId: note.getBranches()[0]?.branchId
});
}
}
}
// Sort by similarity and take the top 'limit' results
const sortedResults = allResults
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
log.info(`Total unique relevant notes found across all queries: ${sortedResults.length}`);
// Cache the results
this.recentQueriesCache.set(cacheKey, {
timestamp: Date.now(),
relevantNotes: sortedResults
});
return sortedResults;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Error finding relevant notes: ${errorMessage}`);
return [];
}
}
/**
* Find notes in a specific branch/context
* @param embedding - Query embedding
* @param contextNoteId - Note ID to restrict search to
* @param limit - Max notes to return
* @returns Array of relevant notes
*/
private async findNotesInBranch(
embedding: Float32Array,
contextNoteId: string,
limit = 5
): Promise<{noteId: string, similarity: number}[]> {
try {
// Get the subtree note IDs
const subtreeNoteIds = await this.getSubtreeNoteIds(contextNoteId);
if (subtreeNoteIds.length === 0) {
return [];
}
// Get all embeddings for these notes using vectorStore instead of direct SQL
const similarities: {noteId: string, similarity: number}[] = [];
for (const noteId of subtreeNoteIds) {
const noteEmbedding = await vectorStore.getEmbeddingForNote(
noteId,
this.provider.name, // Use name property instead of id
this.provider.getConfig().model // Use getConfig().model instead of modelId
);
if (noteEmbedding) {
const similarity = cosineSimilarity(embedding, noteEmbedding.embedding);
if (similarity > 0.5) { // Apply similarity threshold
similarities.push({
noteId,
similarity
});
}
}
}
// Sort by similarity and return top results
return similarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Error finding notes in branch: ${errorMessage}`);
return [];
}
}
/**
* Get all note IDs in a subtree (including the root note)
* @param rootNoteId - Root note ID
* @returns Array of note IDs
*/
private async getSubtreeNoteIds(rootNoteId: string): Promise<string[]> {
const note = becca.notes[rootNoteId];
if (!note) {
return [];
}
// Use becca to walk the note tree instead of direct SQL
const noteIds = new Set<string>([rootNoteId]);
// Helper function to collect all children
const collectChildNotes = (noteId: string) => {
// Use becca.getNote(noteId).getChildNotes() to get child notes
const parentNote = becca.notes[noteId];
if (!parentNote) return;
// Get all branches where this note is the parent
for (const branch of Object.values(becca.branches)) {
if (branch.parentNoteId === noteId && !branch.isDeleted) {
const childNoteId = branch.noteId;
if (!noteIds.has(childNoteId)) {
noteIds.add(childNoteId);
// Recursively collect children of this child
collectChildNotes(childNoteId);
}
}
}
};
// Start collecting from the root
collectChildNotes(rootNoteId);
return Array.from(noteIds);
}
/**
* Build context string from retrieved notes
*/
async buildContextFromNotes(sources: any[], query: string): Promise<string> {
if (!sources || sources.length === 0) {
// Return a default context instead of empty string
return "I am an AI assistant helping you with your Trilium notes. " +
"I couldn't find any specific notes related to your query, but I'll try to assist you " +
"with general knowledge about Trilium or other topics you're interested in.";
}
// Get provider name to adjust context for different models
const providerId = this.provider?.name || 'default';
// Import the constants dynamically to avoid circular dependencies
const { LLM_CONSTANTS } = await import('../../routes/api/llm.js');
// Get appropriate context size and format based on provider
const maxTotalLength =
providerId === 'openai' ? LLM_CONSTANTS.CONTEXT_WINDOW.OPENAI :
providerId === 'anthropic' ? LLM_CONSTANTS.CONTEXT_WINDOW.ANTHROPIC :
providerId === 'ollama' ? LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA :
LLM_CONSTANTS.CONTEXT_WINDOW.DEFAULT;
// Use a format appropriate for the model family
// Anthropic has a specific system message format that works better with certain structures
const isAnthropicFormat = providerId === 'anthropic';
// Start with different headers based on provider
let context = isAnthropicFormat
? `I'm your AI assistant helping with your Trilium notes database. For your query: "${query}", I found these relevant notes:\n\n`
: `I've found some relevant information in your notes that may help answer: "${query}"\n\n`;
// Sort sources by similarity if available to prioritize most relevant
if (sources[0] && sources[0].similarity !== undefined) {
sources = [...sources].sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
}
// Track total context length to avoid oversized context
let currentLength = context.length;
const maxNoteContentLength = Math.min(LLM_CONSTANTS.CONTENT.MAX_NOTE_CONTENT_LENGTH,
Math.floor(maxTotalLength / Math.max(1, sources.length)));
sources.forEach((source) => {
// Check if adding this source would exceed our total limit
if (currentLength >= maxTotalLength) return;
// Build source section with formatting appropriate for the provider
let sourceSection = `### ${source.title}\n`;
// Add relationship context if available
if (source.parentTitle) {
sourceSection += `Part of: ${source.parentTitle}\n`;
}
// Add attributes if available (for better context)
if (source.noteId) {
const note = becca.notes[source.noteId];
if (note) {
const labels = note.getLabels();
if (labels.length > 0) {
sourceSection += `Labels: ${labels.map(l => `#${l.name}${l.value ? '=' + l.value : ''}`).join(' ')}\n`;
}
}
}
if (source.content) {
// Clean up HTML content before adding it to the context
let cleanContent = this.sanitizeNoteContent(source.content, source.type, source.mime);
// Truncate content if it's too long
if (cleanContent.length > maxNoteContentLength) {
cleanContent = cleanContent.substring(0, maxNoteContentLength) + " [content truncated due to length]";
}
sourceSection += `${cleanContent}\n`;
} else {
sourceSection += "[This note doesn't contain textual content]\n";
}
sourceSection += "\n";
// Check if adding this section would exceed total length limit
if (currentLength + sourceSection.length <= maxTotalLength) {
context += sourceSection;
currentLength += sourceSection.length;
}
});
// Add provider-specific instructions
if (isAnthropicFormat) {
context += "When you refer to any information from these notes, cite the note title explicitly (e.g., \"According to the note [Title]...\"). " +
"If the provided notes don't answer the query fully, acknowledge that and then use your general knowledge to help.\n\n" +
"Be concise but thorough in your responses.";
} else {
context += "When referring to information from these notes in your response, please cite them by their titles " +
"(e.g., \"According to your note on [Title]...\") rather than using labels like \"Note 1\" or \"Note 2\".\n\n" +
"If the information doesn't contain what you need, just say so and use your general knowledge instead.";
}
return context;
}
/**
* Sanitize note content for use in context, removing HTML tags
*/
private sanitizeNoteContent(content: string, type?: string, mime?: string): string {
if (!content) return '';
// If it's likely HTML content
if (
(type === 'text' && mime === 'text/html') ||
content.includes('<div') ||
content.includes('<p>') ||
content.includes('<span')
) {
// Use sanitizeHtml to remove all HTML tags
content = sanitizeHtml(content, {
allowedTags: [],
allowedAttributes: {},
textFilter: (text) => {
// Replace multiple newlines with a single one
return text.replace(/\n\s*\n/g, '\n\n');
}
});
// Additional cleanup for remaining HTML entities
content = content
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
// Normalize whitespace
content = content.replace(/\s+/g, ' ').trim();
return content;
}
/**
* Process a user query to find relevant context in Trilium notes
*/
async processQuery(
userQuestion: string,
llmService: any,
contextNoteId: string | null = null,
showThinking: boolean = false
) {
log.info(`Processing query with: question="${userQuestion.substring(0, 50)}...", noteId=${contextNoteId}, showThinking=${showThinking}`);
if (!this.initialized) {
try {
await this.initialize();
} catch (error) {
log.error(`Failed to initialize TriliumContextService: ${error}`);
// Return a fallback response if initialization fails
return {
context: "I am an AI assistant helping you with your Trilium notes. " +
"I'll try to assist you with general knowledge about your query.",
notes: [],
queries: [userQuestion]
};
}
}
try {
// Step 1: Generate search queries
let searchQueries: string[];
try {
searchQueries = await this.generateSearchQueries(userQuestion, llmService);
} catch (error) {
log.error(`Error generating search queries, using fallback: ${error}`);
searchQueries = [userQuestion]; // Fallback to using the original question
}
log.info(`Generated search queries: ${JSON.stringify(searchQueries)}`);
// Step 2: Find relevant notes using those queries
let relevantNotes: any[] = [];
try {
relevantNotes = await this.findRelevantNotesMultiQuery(
searchQueries,
contextNoteId,
8 // Get more notes since we're using multiple queries
);
} catch (error) {
log.error(`Error finding relevant notes: ${error}`);
// Continue with empty notes list
}
// Step 3: Build context from the notes
const context = await this.buildContextFromNotes(relevantNotes, userQuestion);
// Step 4: Add agent tools context with thinking process if requested
let enhancedContext = context;
try {
// Get agent tools context using either the specific note or the most relevant notes
const agentContext = await this.getAgentToolsContext(
contextNoteId || (relevantNotes[0]?.noteId || ""),
userQuestion,
showThinking,
relevantNotes // Pass all relevant notes for context
);
if (agentContext) {
enhancedContext = `${context}\n\n${agentContext}`;
log.info(`Added agent tools context (${agentContext.length} characters)`);
}
} catch (error) {
log.error(`Error getting agent tools context: ${error}`);
// Continue with just the basic context
}
return {
context: enhancedContext,
notes: relevantNotes,
queries: searchQueries
};
} catch (error) {
log.error(`Error in processQuery: ${error}`);
// Return a fallback response if anything fails
return {
context: "I am an AI assistant helping you with your Trilium notes. " +
"I encountered an error while processing your query, but I'll try to assist you anyway.",
notes: [],
queries: [userQuestion]
};
}
}
/**
* Enhance LLM context with agent tools
*
* This adds context from agent tools such as:
* 1. Vector search results relevant to the query
* 2. Note hierarchy information
* 3. Query decomposition planning
* 4. Contextual thinking visualization
*
* @param noteId The current note being viewed (or most relevant note)
* @param query The user's query
* @param showThinking Whether to include the agent's thinking process
* @param relevantNotes Optional array of relevant notes from vector search
* @returns Enhanced context string
*/
async getAgentToolsContext(
noteId: string,
query: string,
showThinking: boolean = false,
relevantNotes: Array<any> = []
): Promise<string> {
log.info(`Getting agent tools context: noteId=${noteId}, query="${query.substring(0, 50)}...", showThinking=${showThinking}, relevantNotesCount=${relevantNotes.length}`);
try {
const agentTools = aiServiceManager.getAgentTools();
let context = "";
// 1. Get vector search results related to the query
try {
// If we already have relevant notes from vector search, use those
if (relevantNotes && relevantNotes.length > 0) {
log.info(`Using ${relevantNotes.length} provided relevant notes instead of running vector search again`);
context += "## Related Information\n\n";
for (const result of relevantNotes.slice(0, 5)) {
context += `### ${result.title}\n`;
// Use the content if available, otherwise get a preview
const contentPreview = result.content
? this.sanitizeNoteContent(result.content).substring(0, 300) + "..."
: result.contentPreview || "[No preview available]";
context += `${contentPreview}\n\n`;
}
context += "\n";
} else {
// Run vector search if we don't have relevant notes
const vectorSearchTool = agentTools.getVectorSearchTool();
const searchResults = await vectorSearchTool.searchNotes(query, {
parentNoteId: noteId,
maxResults: 5
});
if (searchResults.length > 0) {
context += "## Related Information\n\n";
for (const result of searchResults) {
context += `### ${result.title}\n`;
context += `${result.contentPreview}\n\n`;
}
context += "\n";
}
}
} catch (error: any) {
log.error(`Error getting vector search context: ${error.message}`);
}
// 2. Get note structure context
try {
const navigatorTool = agentTools.getNoteNavigatorTool();
const noteContext = navigatorTool.getNoteContextDescription(noteId);
if (noteContext) {
context += "## Current Note Context\n\n";
context += noteContext + "\n\n";
}
} catch (error: any) {
log.error(`Error getting note structure context: ${error.message}`);
}
// 3. Use query decomposition if it's a complex query
try {
const decompositionTool = agentTools.getQueryDecompositionTool();
const complexity = decompositionTool.assessQueryComplexity(query);
if (complexity > 5) { // Only for fairly complex queries
const decomposed = decompositionTool.decomposeQuery(query);
if (decomposed.subQueries.length > 1) {
context += "## Query Analysis\n\n";
context += `This is a complex query (complexity: ${complexity}/10). It can be broken down into:\n\n`;
for (const sq of decomposed.subQueries) {
context += `- ${sq.text}\n Reason: ${sq.reason}\n\n`;
}
}
}
} catch (error: any) {
log.error(`Error decomposing query: ${error.message}`);
}
// 4. Show thinking process if enabled
if (showThinking) {
log.info("Showing thinking process - creating visual reasoning steps");
try {
const thinkingTool = agentTools.getContextualThinkingTool();
const thinkingId = thinkingTool.startThinking(query);
log.info(`Started thinking process with ID: ${thinkingId}`);
// Add initial thinking steps
thinkingTool.addThinkingStep(
"Analyzing the user's query to understand the information needs",
"observation",
{ confidence: 1.0 }
);
// Add query exploration steps
const parentId = thinkingTool.addThinkingStep(
"Exploring knowledge base to find relevant information",
"hypothesis",
{ confidence: 0.9 }
);
// Add information about relevant notes if available
if (relevantNotes && relevantNotes.length > 0) {
const noteTitles = relevantNotes.slice(0, 5).map(n => n.title).join(", ");
thinkingTool.addThinkingStep(
`Found ${relevantNotes.length} potentially relevant notes through semantic search, including: ${noteTitles}`,
"evidence",
{ confidence: 0.85, parentId: parentId || undefined }
);
}
// Add step about note hierarchy if a specific note is being viewed
if (noteId && noteId !== "") {
try {
const navigatorTool = agentTools.getNoteNavigatorTool();
// Get parent notes since we don't have getNoteHierarchyInfo
const parents = navigatorTool.getParentNotes(noteId);
if (parents && parents.length > 0) {
const parentInfo = parents.map(p => p.title).join(" > ");
thinkingTool.addThinkingStep(
`Identified note hierarchy context: ${parentInfo}`,
"evidence",
{ confidence: 0.9, parentId: parentId || undefined }
);
}
} catch (error) {
log.error(`Error getting note hierarchy: ${error}`);
}
}
// Add query decomposition if it's a complex query
try {
const decompositionTool = agentTools.getQueryDecompositionTool();
const complexity = decompositionTool.assessQueryComplexity(query);
if (complexity > 4) {
thinkingTool.addThinkingStep(
`This is a ${complexity > 7 ? "very complex" : "moderately complex"} query (complexity: ${complexity}/10)`,
"observation",
{ confidence: 0.8 }
);
const decomposed = decompositionTool.decomposeQuery(query);
if (decomposed.subQueries.length > 1) {
const decompId = thinkingTool.addThinkingStep(
"Breaking down query into sub-questions to address systematically",
"hypothesis",
{ confidence: 0.85 }
);
for (const sq of decomposed.subQueries) {
thinkingTool.addThinkingStep(
`Subquery: ${sq.text} - ${sq.reason}`,
"evidence",
{ confidence: 0.8, parentId: decompId || undefined }
);
}
}
} else {
thinkingTool.addThinkingStep(
`This is a straightforward query (complexity: ${complexity}/10) that can be addressed directly`,
"observation",
{ confidence: 0.9 }
);
}
} catch (error) {
log.error(`Error in query decomposition: ${error}`);
}
// Add final conclusions
thinkingTool.addThinkingStep(
"Ready to formulate response based on available information and query understanding",
"conclusion",
{ confidence: 0.95 }
);
// Complete the thinking process and add the visualization to context
thinkingTool.completeThinking(thinkingId);
const visualization = thinkingTool.visualizeThinking(thinkingId);
if (visualization) {
context += "## Reasoning Process\n\n";
context += visualization + "\n\n";
log.info(`Added thinking visualization to context (${visualization.length} characters)`);
}
} catch (error: any) {
log.error(`Error creating thinking visualization: ${error.message}`);
}
}
return context;
} catch (error: any) {
log.error(`Error getting agent tools context: ${error.message}`);
return "";
}
}
}
export default new TriliumContextService();