UNPKG

knowledgegraph-mcp

Version:

MCP server for enabling persistent knowledge storage for Claude through a knowledge graph with multiple storage backends

383 lines 15.1 kB
import Fuse from 'fuse.js'; import { BaseSearchStrategy } from './base-strategy.js'; import { getValidatedSearchLimits } from '../config.js'; /** * SQLite search strategy - uses client-side fuzzy search only * SQLite doesn't have built-in fuzzy search capabilities like PostgreSQL, * so we always use Fuse.js for fuzzy searching */ export class SQLiteFuzzyStrategy extends BaseSearchStrategy { db; project; searchLimits = getValidatedSearchLimits(); constructor(config, db, project) { super(config); this.db = db; this.project = project; } canUseDatabase() { // SQLite doesn't support advanced fuzzy search at database level // Always use client-side search with Fuse.js return false; } async searchDatabase(query, threshold, project) { // SQLite doesn't support database-level fuzzy search // This method should not be called since canUseDatabase() returns false throw new Error('SQLite does not support database-level fuzzy search. Use client-side search instead.'); } searchClientSide(entities, query) { // Handle multiple queries if (Array.isArray(query)) { return this.searchMultipleClientSide(entities, query); } // Single query - use existing logic return this.searchSingleClientSide(entities, query); } searchSingleClientSide(entities, query) { // Use chunking for large entity sets to improve performance if (entities.length > this.searchLimits.clientSideChunkSize) { console.log(`SQLite: Using chunked search for ${entities.length} entities (chunk size: ${this.searchLimits.clientSideChunkSize})`); return this.searchClientSideChunked(entities, query, this.searchLimits.clientSideChunkSize); } const fuseOptions = { threshold: this.config.threshold, distance: 100, includeScore: true, keys: ['name', 'entityType', 'observations', 'tags'], ...this.config.fuseOptions }; const fuse = new Fuse(entities, fuseOptions); const results = fuse.search(query); return results.map(result => result.item); } /** * Get all entities for a project from SQLite database * This is used to load entities for client-side search * Respects maxClientSideEntities limit to prevent memory issues */ async getAllEntities(project) { const searchProject = project || this.project; try { const stmt = this.db.prepare(` SELECT name, entity_type, observations, tags FROM entities WHERE project = ? ORDER BY updated_at DESC, name LIMIT ? `); const rows = stmt.all(searchProject, this.searchLimits.maxClientSideEntities); // Log warning if we hit the limit if (rows.length === this.searchLimits.maxClientSideEntities) { console.warn(`SQLite getAllEntities: Hit maxClientSideEntities limit of ${this.searchLimits.maxClientSideEntities}. Consider increasing KNOWLEDGEGRAPH_SEARCH_MAX_CLIENT_ENTITIES or using database-level search.`); } return rows.map((row) => ({ name: row.name, entityType: row.entity_type, observations: this.safeJsonParse(row.observations, []), tags: this.safeJsonParse(row.tags, []) })); } catch (error) { console.error('Failed to load entities from SQLite:', error); throw error; // Throw to be consistent with PostgreSQL behavior } } /** * Perform exact search at database level for better performance * This can be used as an optimization for exact searches */ async searchExact(query, project) { // Handle multiple queries with optimized SQL if (Array.isArray(query)) { return this.searchExactMultiple(query, project); } // Single query - use existing logic return this.searchExactSingle(query, project); } /** * Optimized multiple exact search using single SQL query with OR conditions * This provides better performance than sequential searches */ async searchExactMultiple(queries, project) { // Handle empty queries array if (queries.length === 0) { return []; } const searchProject = project || this.project; try { // Build OR conditions for multiple terms const conditions = queries.map(() => `(LOWER(name) LIKE ? OR LOWER(entity_type) LIKE ? OR LOWER(observations) LIKE ? OR LOWER(tags) LIKE ?)`).join(' OR '); // Prepare statement with dynamic parameters const stmt = this.db.prepare(` SELECT DISTINCT name, entity_type, observations, tags FROM entities WHERE project = ? AND (${conditions}) ORDER BY updated_at DESC, name LIMIT ? `); // Create parameters array: [project, pattern1, pattern1, pattern1, pattern1, pattern2, ..., limit] const params = [searchProject]; for (const query of queries) { const pattern = `%${query.toLowerCase()}%`; params.push(pattern, pattern, pattern, pattern); } params.push(this.searchLimits.maxResults); const rows = stmt.all(...params); return rows.map((row) => ({ name: row.name, entityType: row.entity_type, observations: this.safeJsonParse(row.observations, []), tags: this.safeJsonParse(row.tags, []) })); } catch (error) { console.error('Failed to perform multiple exact search in SQLite:', error); throw error; // Throw to be consistent with PostgreSQL behavior } } /** * Single exact search implementation */ async searchExactSingle(query, project) { const searchProject = project || this.project; const lowerQuery = query.toLowerCase(); try { const stmt = this.db.prepare(` SELECT name, entity_type, observations, tags FROM entities WHERE project = ? AND ( LOWER(name) LIKE ? OR LOWER(entity_type) LIKE ? OR LOWER(observations) LIKE ? OR LOWER(tags) LIKE ? ) ORDER BY updated_at DESC, name LIMIT ? `); const searchPattern = `%${lowerQuery}%`; const rows = stmt.all(searchProject, searchPattern, searchPattern, searchPattern, searchPattern, this.searchLimits.maxResults); return rows.map((row) => ({ name: row.name, entityType: row.entity_type, observations: this.safeJsonParse(row.observations, []), tags: this.safeJsonParse(row.tags, []) })); } catch (error) { console.error('Failed to perform exact search in SQLite:', error); throw error; // Throw to be consistent with PostgreSQL behavior } } /** * Get all entities with pagination support */ async getAllEntitiesPaginated(pagination, project) { const searchProject = project || this.project; const page = pagination.page || 0; const pageSize = pagination.pageSize || 100; const offset = page * pageSize; try { // Get total count const countStmt = this.db.prepare(` SELECT COUNT(*) as total_count FROM entities WHERE project = ? `); const countResult = countStmt.get(searchProject); const totalCount = countResult.total_count; // Get paginated data const dataStmt = this.db.prepare(` SELECT name, entity_type, observations, tags FROM entities WHERE project = ? ORDER BY updated_at DESC, name LIMIT ? OFFSET ? `); const rows = dataStmt.all(searchProject, pageSize, offset); const entities = rows.map((row) => ({ name: row.name, entityType: row.entity_type, observations: this.safeJsonParse(row.observations, []), tags: this.safeJsonParse(row.tags, []) })); const totalPages = Math.ceil(totalCount / pageSize); return { data: entities, pagination: { currentPage: page, pageSize: pageSize, totalCount: totalCount, totalPages: totalPages, hasNextPage: page < totalPages - 1, hasPreviousPage: page > 0 } }; } catch (error) { console.error('Failed to load paginated entities from SQLite:', error); throw error; } } /** * Perform exact search with pagination support */ async searchExactPaginated(query, pagination, project) { // Handle multiple queries if (Array.isArray(query)) { return this.searchExactMultiplePaginated(query, pagination, project); } // Single query return this.searchExactSinglePaginated(query, pagination, project); } /** * Single exact search with pagination */ async searchExactSinglePaginated(query, pagination, project) { const searchProject = project || this.project; const lowerQuery = query.toLowerCase(); const page = pagination.page || 0; const pageSize = pagination.pageSize || 100; const offset = page * pageSize; try { const searchPattern = `%${lowerQuery}%`; // Get total count const countStmt = this.db.prepare(` SELECT COUNT(*) as total_count FROM entities WHERE project = ? AND ( LOWER(name) LIKE ? OR LOWER(entity_type) LIKE ? OR LOWER(observations) LIKE ? OR LOWER(tags) LIKE ? ) `); const countResult = countStmt.get(searchProject, searchPattern, searchPattern, searchPattern, searchPattern); const totalCount = countResult.total_count; // Get paginated data const dataStmt = this.db.prepare(` SELECT name, entity_type, observations, tags FROM entities WHERE project = ? AND ( LOWER(name) LIKE ? OR LOWER(entity_type) LIKE ? OR LOWER(observations) LIKE ? OR LOWER(tags) LIKE ? ) ORDER BY updated_at DESC, name LIMIT ? OFFSET ? `); const rows = dataStmt.all(searchProject, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset); const entities = rows.map((row) => ({ name: row.name, entityType: row.entity_type, observations: this.safeJsonParse(row.observations, []), tags: this.safeJsonParse(row.tags, []) })); const totalPages = Math.ceil(totalCount / pageSize); return { data: entities, pagination: { currentPage: page, pageSize: pageSize, totalCount: totalCount, totalPages: totalPages, hasNextPage: page < totalPages - 1, hasPreviousPage: page > 0 } }; } catch (error) { console.error('Failed to perform paginated exact search in SQLite:', error); throw error; } } /** * Multiple exact search with pagination */ async searchExactMultiplePaginated(queries, pagination, project) { if (queries.length === 0) { return { data: [], pagination: { currentPage: pagination.page || 0, pageSize: pagination.pageSize || 100, totalCount: 0, totalPages: 0, hasNextPage: false, hasPreviousPage: false } }; } const searchProject = project || this.project; const page = pagination.page || 0; const pageSize = pagination.pageSize || 100; const offset = page * pageSize; try { // Build OR conditions for multiple terms const conditions = queries.map(() => `(LOWER(name) LIKE ? OR LOWER(entity_type) LIKE ? OR LOWER(observations) LIKE ? OR LOWER(tags) LIKE ?)`).join(' OR '); // Create parameters array for patterns const params = []; for (const query of queries) { const pattern = `%${query.toLowerCase()}%`; params.push(pattern, pattern, pattern, pattern); } // Get total count const countStmt = this.db.prepare(` SELECT COUNT(DISTINCT name) as total_count FROM entities WHERE project = ? AND (${conditions}) `); const countResult = countStmt.get(searchProject, ...params); const totalCount = countResult.total_count; // Get paginated data const dataStmt = this.db.prepare(` SELECT DISTINCT name, entity_type, observations, tags FROM entities WHERE project = ? AND (${conditions}) ORDER BY updated_at DESC, name LIMIT ? OFFSET ? `); const rows = dataStmt.all(searchProject, ...params, pageSize, offset); const entities = rows.map((row) => ({ name: row.name, entityType: row.entity_type, observations: this.safeJsonParse(row.observations, []), tags: this.safeJsonParse(row.tags, []) })); const totalPages = Math.ceil(totalCount / pageSize); return { data: entities, pagination: { currentPage: page, pageSize: pageSize, totalCount: totalCount, totalPages: totalPages, hasNextPage: page < totalPages - 1, hasPreviousPage: page > 0 } }; } catch (error) { console.error('Failed to perform paginated multiple exact search in SQLite:', error); throw error; } } /** * Safely parse JSON with fallback to default value */ safeJsonParse(jsonString, defaultValue) { if (!jsonString) { return defaultValue; } try { return JSON.parse(jsonString); } catch (error) { return defaultValue; } } } //# sourceMappingURL=sqlite-strategy.js.map