@danielsimonjr/memory-mcp 0.7.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/__tests__/edge-cases/edge-cases.test.js +406 -0
  2. package/dist/__tests__/file-path.test.js +5 -5
  3. package/dist/__tests__/integration/workflows.test.js +449 -0
  4. package/dist/__tests__/knowledge-graph.test.js +8 -3
  5. package/dist/__tests__/performance/benchmarks.test.js +411 -0
  6. package/dist/__tests__/unit/core/EntityManager.test.js +334 -0
  7. package/dist/__tests__/unit/core/GraphStorage.test.js +205 -0
  8. package/dist/__tests__/unit/core/RelationManager.test.js +274 -0
  9. package/dist/__tests__/unit/features/CompressionManager.test.js +350 -0
  10. package/dist/__tests__/unit/search/BasicSearch.test.js +311 -0
  11. package/dist/__tests__/unit/search/BooleanSearch.test.js +432 -0
  12. package/dist/__tests__/unit/search/FuzzySearch.test.js +448 -0
  13. package/dist/__tests__/unit/search/RankedSearch.test.js +379 -0
  14. package/dist/__tests__/unit/utils/levenshtein.test.js +77 -0
  15. package/dist/core/EntityManager.js +554 -0
  16. package/dist/core/GraphStorage.js +172 -0
  17. package/dist/core/KnowledgeGraphManager.js +400 -0
  18. package/dist/core/ObservationManager.js +129 -0
  19. package/dist/core/RelationManager.js +186 -0
  20. package/dist/core/TransactionManager.js +389 -0
  21. package/dist/core/index.js +9 -0
  22. package/dist/features/AnalyticsManager.js +222 -0
  23. package/dist/features/ArchiveManager.js +74 -0
  24. package/dist/features/BackupManager.js +311 -0
  25. package/dist/features/CompressionManager.js +310 -0
  26. package/dist/features/ExportManager.js +305 -0
  27. package/dist/features/HierarchyManager.js +219 -0
  28. package/dist/features/ImportExportManager.js +50 -0
  29. package/dist/features/ImportManager.js +328 -0
  30. package/dist/features/TagManager.js +210 -0
  31. package/dist/features/index.js +12 -0
  32. package/dist/index.js +13 -996
  33. package/dist/memory.jsonl +225 -0
  34. package/dist/search/BasicSearch.js +161 -0
  35. package/dist/search/BooleanSearch.js +304 -0
  36. package/dist/search/FuzzySearch.js +115 -0
  37. package/dist/search/RankedSearch.js +206 -0
  38. package/dist/search/SavedSearchManager.js +145 -0
  39. package/dist/search/SearchManager.js +305 -0
  40. package/dist/search/SearchSuggestions.js +57 -0
  41. package/dist/search/TFIDFIndexManager.js +217 -0
  42. package/dist/search/index.js +10 -0
  43. package/dist/server/MCPServer.js +889 -0
  44. package/dist/types/analytics.types.js +6 -0
  45. package/dist/types/entity.types.js +7 -0
  46. package/dist/types/import-export.types.js +7 -0
  47. package/dist/types/index.js +12 -0
  48. package/dist/types/search.types.js +7 -0
  49. package/dist/types/tag.types.js +6 -0
  50. package/dist/utils/constants.js +127 -0
  51. package/dist/utils/dateUtils.js +89 -0
  52. package/dist/utils/errors.js +121 -0
  53. package/dist/utils/index.js +13 -0
  54. package/dist/utils/levenshtein.js +62 -0
  55. package/dist/utils/logger.js +33 -0
  56. package/dist/utils/pathUtils.js +115 -0
  57. package/dist/utils/schemas.js +184 -0
  58. package/dist/utils/searchCache.js +209 -0
  59. package/dist/utils/tfidf.js +90 -0
  60. package/dist/utils/validationUtils.js +109 -0
  61. package/package.json +50 -48
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Basic Search
3
+ *
4
+ * Simple text-based search with tag, importance, and date filters with result caching.
5
+ *
6
+ * @module search/BasicSearch
7
+ */
8
+ import { isWithinDateRange } from '../utils/dateUtils.js';
9
+ import { SEARCH_LIMITS } from '../utils/constants.js';
10
+ import { searchCaches } from '../utils/searchCache.js';
11
+ /**
12
+ * Performs basic text search with optional filters and caching.
13
+ */
14
+ export class BasicSearch {
15
+ storage;
16
+ enableCache;
17
+ constructor(storage, enableCache = true) {
18
+ this.storage = storage;
19
+ this.enableCache = enableCache;
20
+ }
21
+ /**
22
+ * Search nodes by text query with optional filters and pagination.
23
+ *
24
+ * Searches across entity names, types, and observations.
25
+ *
26
+ * @param query - Text to search for (case-insensitive)
27
+ * @param tags - Optional tags to filter by
28
+ * @param minImportance - Optional minimum importance (0-10)
29
+ * @param maxImportance - Optional maximum importance (0-10)
30
+ * @param offset - Number of results to skip (default: 0)
31
+ * @param limit - Maximum number of results (default: 50, max: 200)
32
+ * @returns Filtered knowledge graph with pagination applied
33
+ */
34
+ async searchNodes(query, tags, minImportance, maxImportance, offset = 0, limit = SEARCH_LIMITS.DEFAULT) {
35
+ // Check cache first
36
+ if (this.enableCache) {
37
+ const cacheKey = { query, tags, minImportance, maxImportance, offset, limit };
38
+ const cached = searchCaches.basic.get(cacheKey);
39
+ if (cached) {
40
+ return cached;
41
+ }
42
+ }
43
+ const graph = await this.storage.loadGraph();
44
+ const normalizedTags = tags?.map(tag => tag.toLowerCase());
45
+ // Validate pagination parameters
46
+ const validatedOffset = Math.max(0, offset);
47
+ const validatedLimit = Math.min(Math.max(SEARCH_LIMITS.MIN, limit), SEARCH_LIMITS.MAX);
48
+ const filteredEntities = graph.entities.filter(e => {
49
+ // Text search
50
+ const matchesQuery = e.name.toLowerCase().includes(query.toLowerCase()) ||
51
+ e.entityType.toLowerCase().includes(query.toLowerCase()) ||
52
+ e.observations.some(o => o.toLowerCase().includes(query.toLowerCase()));
53
+ if (!matchesQuery)
54
+ return false;
55
+ // Tag filter
56
+ if (normalizedTags && normalizedTags.length > 0) {
57
+ if (!e.tags || e.tags.length === 0)
58
+ return false;
59
+ const entityTags = e.tags.map(tag => tag.toLowerCase());
60
+ const hasMatchingTag = normalizedTags.some(tag => entityTags.includes(tag));
61
+ if (!hasMatchingTag)
62
+ return false;
63
+ }
64
+ // Importance filter
65
+ if (minImportance !== undefined && (e.importance === undefined || e.importance < minImportance)) {
66
+ return false;
67
+ }
68
+ if (maxImportance !== undefined && (e.importance === undefined || e.importance > maxImportance)) {
69
+ return false;
70
+ }
71
+ return true;
72
+ });
73
+ // Apply pagination
74
+ const paginatedEntities = filteredEntities.slice(validatedOffset, validatedOffset + validatedLimit);
75
+ const filteredEntityNames = new Set(paginatedEntities.map(e => e.name));
76
+ const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
77
+ const result = { entities: paginatedEntities, relations: filteredRelations };
78
+ // Cache the result
79
+ if (this.enableCache) {
80
+ const cacheKey = { query, tags, minImportance, maxImportance, offset, limit };
81
+ searchCaches.basic.set(cacheKey, result);
82
+ }
83
+ return result;
84
+ }
85
+ /**
86
+ * Open specific nodes by name.
87
+ *
88
+ * @param names - Array of entity names to retrieve
89
+ * @returns Knowledge graph with specified entities and their relations
90
+ */
91
+ async openNodes(names) {
92
+ const graph = await this.storage.loadGraph();
93
+ const filteredEntities = graph.entities.filter(e => names.includes(e.name));
94
+ const filteredEntityNames = new Set(filteredEntities.map(e => e.name));
95
+ const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
96
+ return { entities: filteredEntities, relations: filteredRelations };
97
+ }
98
+ /**
99
+ * Search by date range with optional filters and pagination.
100
+ *
101
+ * @param startDate - Optional start date (ISO 8601)
102
+ * @param endDate - Optional end date (ISO 8601)
103
+ * @param entityType - Optional entity type filter
104
+ * @param tags - Optional tags filter
105
+ * @param offset - Number of results to skip (default: 0)
106
+ * @param limit - Maximum number of results (default: 50, max: 200)
107
+ * @returns Filtered knowledge graph with pagination applied
108
+ */
109
+ async searchByDateRange(startDate, endDate, entityType, tags, offset = 0, limit = SEARCH_LIMITS.DEFAULT) {
110
+ // Check cache first
111
+ if (this.enableCache) {
112
+ const cacheKey = { method: 'dateRange', startDate, endDate, entityType, tags, offset, limit };
113
+ const cached = searchCaches.basic.get(cacheKey);
114
+ if (cached) {
115
+ return cached;
116
+ }
117
+ }
118
+ const graph = await this.storage.loadGraph();
119
+ const normalizedTags = tags?.map(tag => tag.toLowerCase());
120
+ // Validate pagination parameters
121
+ const validatedOffset = Math.max(0, offset);
122
+ const validatedLimit = Math.min(Math.max(SEARCH_LIMITS.MIN, limit), SEARCH_LIMITS.MAX);
123
+ const filteredEntities = graph.entities.filter(e => {
124
+ // Date filter (use createdAt or lastModified)
125
+ const dateToCheck = e.createdAt || e.lastModified;
126
+ if (dateToCheck && !isWithinDateRange(dateToCheck, startDate, endDate)) {
127
+ return false;
128
+ }
129
+ // Entity type filter
130
+ if (entityType && e.entityType !== entityType) {
131
+ return false;
132
+ }
133
+ // Tags filter
134
+ if (normalizedTags && normalizedTags.length > 0) {
135
+ if (!e.tags || e.tags.length === 0)
136
+ return false;
137
+ const entityTags = e.tags.map(tag => tag.toLowerCase());
138
+ const hasMatchingTag = normalizedTags.some(tag => entityTags.includes(tag));
139
+ if (!hasMatchingTag)
140
+ return false;
141
+ }
142
+ return true;
143
+ });
144
+ // Apply pagination
145
+ const paginatedEntities = filteredEntities.slice(validatedOffset, validatedOffset + validatedLimit);
146
+ const filteredEntityNames = new Set(paginatedEntities.map(e => e.name));
147
+ const filteredRelations = graph.relations.filter(r => {
148
+ const dateToCheck = r.createdAt || r.lastModified;
149
+ const inDateRange = !dateToCheck || isWithinDateRange(dateToCheck, startDate, endDate);
150
+ const involvesFilteredEntities = filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to);
151
+ return inDateRange && involvesFilteredEntities;
152
+ });
153
+ const result = { entities: paginatedEntities, relations: filteredRelations };
154
+ // Cache the result
155
+ if (this.enableCache) {
156
+ const cacheKey = { method: 'dateRange', startDate, endDate, entityType, tags, offset, limit };
157
+ searchCaches.basic.set(cacheKey, result);
158
+ }
159
+ return result;
160
+ }
161
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Boolean Search
3
+ *
4
+ * Advanced search with boolean operators (AND, OR, NOT) and field-specific queries.
5
+ *
6
+ * @module search/BooleanSearch
7
+ */
8
+ import { SEARCH_LIMITS, QUERY_LIMITS } from '../utils/constants.js';
9
+ import { ValidationError } from '../utils/errors.js';
10
+ /**
11
+ * Performs boolean search with query parsing and AST evaluation.
12
+ */
13
+ export class BooleanSearch {
14
+ storage;
15
+ constructor(storage) {
16
+ this.storage = storage;
17
+ }
18
+ /**
19
+ * Boolean search with support for AND, OR, NOT operators, field-specific queries, and pagination.
20
+ *
21
+ * Query syntax examples:
22
+ * - "alice AND programming" - Both terms must match
23
+ * - "type:person OR type:organization" - Either type matches
24
+ * - "NOT archived" - Exclude archived items
25
+ * - "name:alice AND (observation:coding OR observation:teaching)"
26
+ *
27
+ * @param query - Boolean query string
28
+ * @param tags - Optional tags filter
29
+ * @param minImportance - Optional minimum importance
30
+ * @param maxImportance - Optional maximum importance
31
+ * @param offset - Number of results to skip (default: 0)
32
+ * @param limit - Maximum number of results (default: 50, max: 200)
33
+ * @returns Filtered knowledge graph matching the boolean query with pagination applied
34
+ */
35
+ async booleanSearch(query, tags, minImportance, maxImportance, offset = 0, limit = SEARCH_LIMITS.DEFAULT) {
36
+ // Validate query length
37
+ if (query.length > QUERY_LIMITS.MAX_QUERY_LENGTH) {
38
+ throw new ValidationError('Query too long', [`Query length ${query.length} exceeds maximum of ${QUERY_LIMITS.MAX_QUERY_LENGTH} characters`]);
39
+ }
40
+ const graph = await this.storage.loadGraph();
41
+ // Parse the query into an AST
42
+ let queryAst;
43
+ try {
44
+ queryAst = this.parseBooleanQuery(query);
45
+ }
46
+ catch (error) {
47
+ throw new Error(`Failed to parse boolean query: ${error instanceof Error ? error.message : String(error)}`);
48
+ }
49
+ // Validate query complexity
50
+ this.validateQueryComplexity(queryAst);
51
+ const normalizedTags = tags?.map(tag => tag.toLowerCase());
52
+ // Validate pagination parameters
53
+ const validatedOffset = Math.max(0, offset);
54
+ const validatedLimit = Math.min(Math.max(SEARCH_LIMITS.MIN, limit), SEARCH_LIMITS.MAX);
55
+ // Filter entities
56
+ const filteredEntities = graph.entities.filter(e => {
57
+ // Evaluate boolean query
58
+ if (!this.evaluateBooleanQuery(queryAst, e)) {
59
+ return false;
60
+ }
61
+ // Apply tag filter
62
+ if (normalizedTags && normalizedTags.length > 0) {
63
+ if (!e.tags || e.tags.length === 0)
64
+ return false;
65
+ const entityTags = e.tags.map(tag => tag.toLowerCase());
66
+ const hasMatchingTag = normalizedTags.some(tag => entityTags.includes(tag));
67
+ if (!hasMatchingTag)
68
+ return false;
69
+ }
70
+ // Apply importance filter
71
+ if (minImportance !== undefined && (e.importance === undefined || e.importance < minImportance)) {
72
+ return false;
73
+ }
74
+ if (maxImportance !== undefined && (e.importance === undefined || e.importance > maxImportance)) {
75
+ return false;
76
+ }
77
+ return true;
78
+ });
79
+ // Apply pagination
80
+ const paginatedEntities = filteredEntities.slice(validatedOffset, validatedOffset + validatedLimit);
81
+ const filteredEntityNames = new Set(paginatedEntities.map(e => e.name));
82
+ const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
83
+ return { entities: paginatedEntities, relations: filteredRelations };
84
+ }
85
+ /**
86
+ * Tokenize a boolean query into tokens.
87
+ *
88
+ * Handles quoted strings, parentheses, and operators.
89
+ */
90
+ tokenizeBooleanQuery(query) {
91
+ const tokens = [];
92
+ let current = '';
93
+ let inQuotes = false;
94
+ for (let i = 0; i < query.length; i++) {
95
+ const char = query[i];
96
+ if (char === '"') {
97
+ if (inQuotes) {
98
+ // End of quoted string
99
+ tokens.push(current);
100
+ current = '';
101
+ inQuotes = false;
102
+ }
103
+ else {
104
+ // Start of quoted string
105
+ if (current.trim()) {
106
+ tokens.push(current.trim());
107
+ current = '';
108
+ }
109
+ inQuotes = true;
110
+ }
111
+ }
112
+ else if (!inQuotes && (char === '(' || char === ')')) {
113
+ // Parentheses are separate tokens
114
+ if (current.trim()) {
115
+ tokens.push(current.trim());
116
+ current = '';
117
+ }
118
+ tokens.push(char);
119
+ }
120
+ else if (!inQuotes && /\s/.test(char)) {
121
+ // Whitespace outside quotes
122
+ if (current.trim()) {
123
+ tokens.push(current.trim());
124
+ current = '';
125
+ }
126
+ }
127
+ else {
128
+ current += char;
129
+ }
130
+ }
131
+ if (current.trim()) {
132
+ tokens.push(current.trim());
133
+ }
134
+ return tokens;
135
+ }
136
+ /**
137
+ * Parse a boolean search query into an AST.
138
+ *
139
+ * Supports: AND, OR, NOT, parentheses, field-specific queries (field:value)
140
+ */
141
+ parseBooleanQuery(query) {
142
+ const tokens = this.tokenizeBooleanQuery(query);
143
+ let position = 0;
144
+ const peek = () => tokens[position];
145
+ const consume = () => tokens[position++];
146
+ // Parse OR expressions (lowest precedence)
147
+ const parseOr = () => {
148
+ let left = parseAnd();
149
+ while (peek()?.toUpperCase() === 'OR') {
150
+ consume(); // consume 'OR'
151
+ const right = parseAnd();
152
+ left = { type: 'OR', children: [left, right] };
153
+ }
154
+ return left;
155
+ };
156
+ // Parse AND expressions
157
+ const parseAnd = () => {
158
+ let left = parseNot();
159
+ while (peek() && peek()?.toUpperCase() !== 'OR' && peek() !== ')') {
160
+ // Implicit AND if next token is not OR or )
161
+ if (peek()?.toUpperCase() === 'AND') {
162
+ consume(); // consume 'AND'
163
+ }
164
+ const right = parseNot();
165
+ left = { type: 'AND', children: [left, right] };
166
+ }
167
+ return left;
168
+ };
169
+ // Parse NOT expressions
170
+ const parseNot = () => {
171
+ if (peek()?.toUpperCase() === 'NOT') {
172
+ consume(); // consume 'NOT'
173
+ const child = parseNot();
174
+ return { type: 'NOT', child };
175
+ }
176
+ return parsePrimary();
177
+ };
178
+ // Parse primary expressions (terms, field queries, parentheses)
179
+ const parsePrimary = () => {
180
+ const token = peek();
181
+ if (!token) {
182
+ throw new Error('Unexpected end of query');
183
+ }
184
+ // Parentheses
185
+ if (token === '(') {
186
+ consume(); // consume '('
187
+ const node = parseOr();
188
+ if (consume() !== ')') {
189
+ throw new Error('Expected closing parenthesis');
190
+ }
191
+ return node;
192
+ }
193
+ // Field-specific query (field:value)
194
+ if (token.includes(':')) {
195
+ consume();
196
+ const [field, ...valueParts] = token.split(':');
197
+ const value = valueParts.join(':'); // Handle colons in value
198
+ return { type: 'TERM', field: field.toLowerCase(), value: value.toLowerCase() };
199
+ }
200
+ // Regular term
201
+ consume();
202
+ return { type: 'TERM', value: token.toLowerCase() };
203
+ };
204
+ const result = parseOr();
205
+ // Check for unconsumed tokens
206
+ if (position < tokens.length) {
207
+ throw new Error(`Unexpected token: ${tokens[position]}`);
208
+ }
209
+ return result;
210
+ }
211
+ /**
212
+ * Evaluate a boolean query AST against an entity.
213
+ */
214
+ evaluateBooleanQuery(node, entity) {
215
+ switch (node.type) {
216
+ case 'AND':
217
+ return node.children.every(child => this.evaluateBooleanQuery(child, entity));
218
+ case 'OR':
219
+ return node.children.some(child => this.evaluateBooleanQuery(child, entity));
220
+ case 'NOT':
221
+ return !this.evaluateBooleanQuery(node.child, entity);
222
+ case 'TERM': {
223
+ const value = node.value;
224
+ // Field-specific search
225
+ if (node.field) {
226
+ switch (node.field) {
227
+ case 'name':
228
+ return entity.name.toLowerCase().includes(value);
229
+ case 'type':
230
+ case 'entitytype':
231
+ return entity.entityType.toLowerCase().includes(value);
232
+ case 'observation':
233
+ case 'observations':
234
+ return entity.observations.some(obs => obs.toLowerCase().includes(value));
235
+ case 'tag':
236
+ case 'tags':
237
+ return entity.tags ? entity.tags.some(tag => tag.toLowerCase().includes(value)) : false;
238
+ default:
239
+ // Unknown field, search all text fields
240
+ return this.entityMatchesTerm(entity, value);
241
+ }
242
+ }
243
+ // General search across all fields
244
+ return this.entityMatchesTerm(entity, value);
245
+ }
246
+ }
247
+ }
248
+ /**
249
+ * Check if entity matches a search term in any text field.
250
+ */
251
+ entityMatchesTerm(entity, term) {
252
+ const termLower = term.toLowerCase();
253
+ return (entity.name.toLowerCase().includes(termLower) ||
254
+ entity.entityType.toLowerCase().includes(termLower) ||
255
+ entity.observations.some(obs => obs.toLowerCase().includes(termLower)) ||
256
+ (entity.tags?.some(tag => tag.toLowerCase().includes(termLower)) || false));
257
+ }
258
+ /**
259
+ * Validate query complexity to prevent resource exhaustion.
260
+ * Checks nesting depth, term count, and operator count against configured limits.
261
+ */
262
+ validateQueryComplexity(node, depth = 0) {
263
+ // Check nesting depth
264
+ if (depth > QUERY_LIMITS.MAX_DEPTH) {
265
+ throw new ValidationError('Query too complex', [`Query nesting depth ${depth} exceeds maximum of ${QUERY_LIMITS.MAX_DEPTH}`]);
266
+ }
267
+ // Count terms and operators recursively
268
+ const complexity = this.calculateQueryComplexity(node);
269
+ if (complexity.terms > QUERY_LIMITS.MAX_TERMS) {
270
+ throw new ValidationError('Query too complex', [`Query has ${complexity.terms} terms, exceeds maximum of ${QUERY_LIMITS.MAX_TERMS}`]);
271
+ }
272
+ if (complexity.operators > QUERY_LIMITS.MAX_OPERATORS) {
273
+ throw new ValidationError('Query too complex', [`Query has ${complexity.operators} operators, exceeds maximum of ${QUERY_LIMITS.MAX_OPERATORS}`]);
274
+ }
275
+ }
276
+ /**
277
+ * Calculate query complexity metrics.
278
+ */
279
+ calculateQueryComplexity(node, depth = 0) {
280
+ switch (node.type) {
281
+ case 'AND':
282
+ case 'OR':
283
+ const childResults = node.children.map(child => this.calculateQueryComplexity(child, depth + 1));
284
+ return {
285
+ terms: childResults.reduce((sum, r) => sum + r.terms, 0),
286
+ operators: childResults.reduce((sum, r) => sum + r.operators, 1), // +1 for current operator
287
+ maxDepth: Math.max(depth, ...childResults.map(r => r.maxDepth)),
288
+ };
289
+ case 'NOT':
290
+ const notResult = this.calculateQueryComplexity(node.child, depth + 1);
291
+ return {
292
+ terms: notResult.terms,
293
+ operators: notResult.operators + 1,
294
+ maxDepth: Math.max(depth, notResult.maxDepth),
295
+ };
296
+ case 'TERM':
297
+ return {
298
+ terms: 1,
299
+ operators: 0,
300
+ maxDepth: depth,
301
+ };
302
+ }
303
+ }
304
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Fuzzy Search
3
+ *
4
+ * Search with typo tolerance using Levenshtein distance similarity.
5
+ *
6
+ * @module search/FuzzySearch
7
+ */
8
+ import { levenshteinDistance } from '../utils/levenshtein.js';
9
+ import { SEARCH_LIMITS } from '../utils/constants.js';
10
+ /**
11
+ * Default fuzzy search similarity threshold (70% match required).
12
+ * Lower values are more permissive (more typos tolerated).
13
+ * Higher values are stricter (fewer typos tolerated).
14
+ */
15
+ export const DEFAULT_FUZZY_THRESHOLD = 0.7;
16
+ /**
17
+ * Performs fuzzy search with configurable similarity threshold.
18
+ */
19
+ export class FuzzySearch {
20
+ storage;
21
+ constructor(storage) {
22
+ this.storage = storage;
23
+ }
24
+ /**
25
+ * Fuzzy search for entities with typo tolerance and pagination.
26
+ *
27
+ * Uses Levenshtein distance to calculate similarity between strings.
28
+ * Matches if similarity >= threshold (0.0 to 1.0).
29
+ *
30
+ * @param query - Search query
31
+ * @param threshold - Similarity threshold (0.0 to 1.0), default DEFAULT_FUZZY_THRESHOLD
32
+ * @param tags - Optional tags filter
33
+ * @param minImportance - Optional minimum importance
34
+ * @param maxImportance - Optional maximum importance
35
+ * @param offset - Number of results to skip (default: 0)
36
+ * @param limit - Maximum number of results (default: 50, max: 200)
37
+ * @returns Filtered knowledge graph with fuzzy matches and pagination applied
38
+ */
39
+ async fuzzySearch(query, threshold = DEFAULT_FUZZY_THRESHOLD, tags, minImportance, maxImportance, offset = 0, limit = SEARCH_LIMITS.DEFAULT) {
40
+ const graph = await this.storage.loadGraph();
41
+ const normalizedTags = tags?.map(tag => tag.toLowerCase());
42
+ // Validate pagination parameters
43
+ const validatedOffset = Math.max(0, offset);
44
+ const validatedLimit = Math.min(Math.max(SEARCH_LIMITS.MIN, limit), SEARCH_LIMITS.MAX);
45
+ // Filter entities using fuzzy matching
46
+ const filteredEntities = graph.entities.filter(e => {
47
+ // Fuzzy text search
48
+ const matchesQuery = this.isFuzzyMatch(e.name, query, threshold) ||
49
+ this.isFuzzyMatch(e.entityType, query, threshold) ||
50
+ e.observations.some(o =>
51
+ // For observations, split into words and check each word
52
+ o
53
+ .toLowerCase()
54
+ .split(/\s+/)
55
+ .some(word => this.isFuzzyMatch(word, query, threshold)) ||
56
+ // Also check if the observation contains the query
57
+ this.isFuzzyMatch(o, query, threshold));
58
+ if (!matchesQuery)
59
+ return false;
60
+ // Tag filter
61
+ if (normalizedTags && normalizedTags.length > 0) {
62
+ if (!e.tags || e.tags.length === 0)
63
+ return false;
64
+ const entityTags = e.tags.map(tag => tag.toLowerCase());
65
+ const hasMatchingTag = normalizedTags.some(tag => entityTags.includes(tag));
66
+ if (!hasMatchingTag)
67
+ return false;
68
+ }
69
+ // Importance filter
70
+ if (minImportance !== undefined && (e.importance === undefined || e.importance < minImportance)) {
71
+ return false;
72
+ }
73
+ if (maxImportance !== undefined && (e.importance === undefined || e.importance > maxImportance)) {
74
+ return false;
75
+ }
76
+ return true;
77
+ });
78
+ // Apply pagination
79
+ const paginatedEntities = filteredEntities.slice(validatedOffset, validatedOffset + validatedLimit);
80
+ const filteredEntityNames = new Set(paginatedEntities.map(e => e.name));
81
+ const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
82
+ return {
83
+ entities: paginatedEntities,
84
+ relations: filteredRelations,
85
+ };
86
+ }
87
+ /**
88
+ * Check if two strings match with fuzzy logic.
89
+ *
90
+ * Returns true if:
91
+ * - Strings are identical
92
+ * - One contains the other
93
+ * - Levenshtein similarity >= threshold
94
+ *
95
+ * @param str1 - First string
96
+ * @param str2 - Second string
97
+ * @param threshold - Similarity threshold (0.0 to 1.0)
98
+ * @returns True if strings match fuzzily
99
+ */
100
+ isFuzzyMatch(str1, str2, threshold = 0.7) {
101
+ const s1 = str1.toLowerCase();
102
+ const s2 = str2.toLowerCase();
103
+ // Exact match
104
+ if (s1 === s2)
105
+ return true;
106
+ // One contains the other
107
+ if (s1.includes(s2) || s2.includes(s1))
108
+ return true;
109
+ // Calculate similarity using Levenshtein distance
110
+ const distance = levenshteinDistance(s1, s2);
111
+ const maxLength = Math.max(s1.length, s2.length);
112
+ const similarity = 1 - distance / maxLength;
113
+ return similarity >= threshold;
114
+ }
115
+ }