@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.
- package/dist/__tests__/edge-cases/edge-cases.test.js +406 -0
- package/dist/__tests__/file-path.test.js +5 -5
- package/dist/__tests__/integration/workflows.test.js +449 -0
- package/dist/__tests__/knowledge-graph.test.js +8 -3
- package/dist/__tests__/performance/benchmarks.test.js +411 -0
- package/dist/__tests__/unit/core/EntityManager.test.js +334 -0
- package/dist/__tests__/unit/core/GraphStorage.test.js +205 -0
- package/dist/__tests__/unit/core/RelationManager.test.js +274 -0
- package/dist/__tests__/unit/features/CompressionManager.test.js +350 -0
- package/dist/__tests__/unit/search/BasicSearch.test.js +311 -0
- package/dist/__tests__/unit/search/BooleanSearch.test.js +432 -0
- package/dist/__tests__/unit/search/FuzzySearch.test.js +448 -0
- package/dist/__tests__/unit/search/RankedSearch.test.js +379 -0
- package/dist/__tests__/unit/utils/levenshtein.test.js +77 -0
- package/dist/core/EntityManager.js +554 -0
- package/dist/core/GraphStorage.js +172 -0
- package/dist/core/KnowledgeGraphManager.js +400 -0
- package/dist/core/ObservationManager.js +129 -0
- package/dist/core/RelationManager.js +186 -0
- package/dist/core/TransactionManager.js +389 -0
- package/dist/core/index.js +9 -0
- package/dist/features/AnalyticsManager.js +222 -0
- package/dist/features/ArchiveManager.js +74 -0
- package/dist/features/BackupManager.js +311 -0
- package/dist/features/CompressionManager.js +310 -0
- package/dist/features/ExportManager.js +305 -0
- package/dist/features/HierarchyManager.js +219 -0
- package/dist/features/ImportExportManager.js +50 -0
- package/dist/features/ImportManager.js +328 -0
- package/dist/features/TagManager.js +210 -0
- package/dist/features/index.js +12 -0
- package/dist/index.js +13 -996
- package/dist/memory.jsonl +225 -0
- package/dist/search/BasicSearch.js +161 -0
- package/dist/search/BooleanSearch.js +304 -0
- package/dist/search/FuzzySearch.js +115 -0
- package/dist/search/RankedSearch.js +206 -0
- package/dist/search/SavedSearchManager.js +145 -0
- package/dist/search/SearchManager.js +305 -0
- package/dist/search/SearchSuggestions.js +57 -0
- package/dist/search/TFIDFIndexManager.js +217 -0
- package/dist/search/index.js +10 -0
- package/dist/server/MCPServer.js +889 -0
- package/dist/types/analytics.types.js +6 -0
- package/dist/types/entity.types.js +7 -0
- package/dist/types/import-export.types.js +7 -0
- package/dist/types/index.js +12 -0
- package/dist/types/search.types.js +7 -0
- package/dist/types/tag.types.js +6 -0
- package/dist/utils/constants.js +127 -0
- package/dist/utils/dateUtils.js +89 -0
- package/dist/utils/errors.js +121 -0
- package/dist/utils/index.js +13 -0
- package/dist/utils/levenshtein.js +62 -0
- package/dist/utils/logger.js +33 -0
- package/dist/utils/pathUtils.js +115 -0
- package/dist/utils/schemas.js +184 -0
- package/dist/utils/searchCache.js +209 -0
- package/dist/utils/tfidf.js +90 -0
- package/dist/utils/validationUtils.js +109 -0
- 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
|
+
}
|