@danielsimonjr/memory-mcp 0.41.0 → 0.48.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.
@@ -8,6 +8,7 @@
8
8
  import { isWithinDateRange } from '../utils/dateUtils.js';
9
9
  import { SEARCH_LIMITS } from '../utils/constants.js';
10
10
  import { searchCaches } from '../utils/searchCache.js';
11
+ import { SearchFilterChain } from './SearchFilterChain.js';
11
12
  /**
12
13
  * Performs basic text search with optional filters and caching.
13
14
  */
@@ -41,37 +42,19 @@ export class BasicSearch {
41
42
  }
42
43
  }
43
44
  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;
45
+ const queryLower = query.toLowerCase();
46
+ // First filter by text match (search-specific)
47
+ const textMatched = graph.entities.filter(e => {
48
+ return (e.name.toLowerCase().includes(queryLower) ||
49
+ e.entityType.toLowerCase().includes(queryLower) ||
50
+ e.observations.some(o => o.toLowerCase().includes(queryLower)));
72
51
  });
73
- // Apply pagination
74
- const paginatedEntities = filteredEntities.slice(validatedOffset, validatedOffset + validatedLimit);
52
+ // Apply tag and importance filters using SearchFilterChain
53
+ const filters = { tags, minImportance, maxImportance };
54
+ const filteredEntities = SearchFilterChain.applyFilters(textMatched, filters);
55
+ // Apply pagination using SearchFilterChain
56
+ const pagination = SearchFilterChain.validatePagination(offset, limit);
57
+ const paginatedEntities = SearchFilterChain.paginate(filteredEntities, pagination);
75
58
  const filteredEntityNames = new Set(paginatedEntities.map(e => e.name));
76
59
  const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
77
60
  const result = { entities: paginatedEntities, relations: filteredRelations };
@@ -116,33 +99,20 @@ export class BasicSearch {
116
99
  }
117
100
  }
118
101
  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)
102
+ // First filter by date range (search-specific - uses createdAt OR lastModified)
103
+ const dateFiltered = graph.entities.filter(e => {
125
104
  const dateToCheck = e.createdAt || e.lastModified;
126
105
  if (dateToCheck && !isWithinDateRange(dateToCheck, startDate, endDate)) {
127
106
  return false;
128
107
  }
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
108
  return true;
143
109
  });
144
- // Apply pagination
145
- const paginatedEntities = filteredEntities.slice(validatedOffset, validatedOffset + validatedLimit);
110
+ // Apply entity type and tag filters using SearchFilterChain
111
+ const filters = { tags, entityType };
112
+ const filteredEntities = SearchFilterChain.applyFilters(dateFiltered, filters);
113
+ // Apply pagination using SearchFilterChain
114
+ const pagination = SearchFilterChain.validatePagination(offset, limit);
115
+ const paginatedEntities = SearchFilterChain.paginate(filteredEntities, pagination);
146
116
  const filteredEntityNames = new Set(paginatedEntities.map(e => e.name));
147
117
  const filteredRelations = graph.relations.filter(r => {
148
118
  const dateToCheck = r.createdAt || r.lastModified;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { SEARCH_LIMITS, QUERY_LIMITS } from '../utils/constants.js';
9
9
  import { ValidationError } from '../utils/errors.js';
10
+ import { SearchFilterChain } from './SearchFilterChain.js';
10
11
  /**
11
12
  * Performs boolean search with query parsing and AST evaluation.
12
13
  */
@@ -48,36 +49,14 @@ export class BooleanSearch {
48
49
  }
49
50
  // Validate query complexity
50
51
  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);
52
+ // First filter by boolean query evaluation (search-specific)
53
+ const booleanMatched = graph.entities.filter(e => this.evaluateBooleanQuery(queryAst, e));
54
+ // Apply tag and importance filters using SearchFilterChain
55
+ const filters = { tags, minImportance, maxImportance };
56
+ const filteredEntities = SearchFilterChain.applyFilters(booleanMatched, filters);
57
+ // Apply pagination using SearchFilterChain
58
+ const pagination = SearchFilterChain.validatePagination(offset, limit);
59
+ const paginatedEntities = SearchFilterChain.paginate(filteredEntities, pagination);
81
60
  const filteredEntityNames = new Set(paginatedEntities.map(e => e.name));
82
61
  const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
83
62
  return { entities: paginatedEntities, relations: filteredRelations };
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { levenshteinDistance } from '../utils/levenshtein.js';
9
9
  import { SEARCH_LIMITS } from '../utils/constants.js';
10
+ import { SearchFilterChain } from './SearchFilterChain.js';
10
11
  /**
11
12
  * Default fuzzy search similarity threshold (70% match required).
12
13
  * Lower values are more permissive (more typos tolerated).
@@ -38,14 +39,9 @@ export class FuzzySearch {
38
39
  */
39
40
  async fuzzySearch(query, threshold = DEFAULT_FUZZY_THRESHOLD, tags, minImportance, maxImportance, offset = 0, limit = SEARCH_LIMITS.DEFAULT) {
40
41
  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) ||
42
+ // First filter by fuzzy text match (search-specific)
43
+ const fuzzyMatched = graph.entities.filter(e => {
44
+ return (this.isFuzzyMatch(e.name, query, threshold) ||
49
45
  this.isFuzzyMatch(e.entityType, query, threshold) ||
50
46
  e.observations.some(o =>
51
47
  // For observations, split into words and check each word
@@ -54,29 +50,14 @@ export class FuzzySearch {
54
50
  .split(/\s+/)
55
51
  .some(word => this.isFuzzyMatch(word, query, threshold)) ||
56
52
  // 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;
53
+ this.isFuzzyMatch(o, query, threshold)));
77
54
  });
78
- // Apply pagination
79
- const paginatedEntities = filteredEntities.slice(validatedOffset, validatedOffset + validatedLimit);
55
+ // Apply tag and importance filters using SearchFilterChain
56
+ const filters = { tags, minImportance, maxImportance };
57
+ const filteredEntities = SearchFilterChain.applyFilters(fuzzyMatched, filters);
58
+ // Apply pagination using SearchFilterChain
59
+ const pagination = SearchFilterChain.validatePagination(offset, limit);
60
+ const paginatedEntities = SearchFilterChain.paginate(filteredEntities, pagination);
80
61
  const filteredEntityNames = new Set(paginatedEntities.map(e => e.name));
81
62
  const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
82
63
  return {
@@ -8,6 +8,7 @@
8
8
  import { calculateTFIDF, tokenize } from '../utils/tfidf.js';
9
9
  import { SEARCH_LIMITS } from '../utils/constants.js';
10
10
  import { TFIDFIndexManager } from './TFIDFIndexManager.js';
11
+ import { SearchFilterChain } from './SearchFilterChain.js';
11
12
  /**
12
13
  * Performs TF-IDF ranked search with optional pre-calculated indexes.
13
14
  */
@@ -78,26 +79,9 @@ export class RankedSearch {
78
79
  // Enforce maximum search limit
79
80
  const effectiveLimit = Math.min(limit, SEARCH_LIMITS.MAX);
80
81
  const graph = await this.storage.loadGraph();
81
- const normalizedTags = tags?.map(tag => tag.toLowerCase());
82
- // Filter entities by tags and importance
83
- const filteredEntities = graph.entities.filter(e => {
84
- // Tag filter
85
- if (normalizedTags && normalizedTags.length > 0) {
86
- if (!e.tags || e.tags.length === 0)
87
- return false;
88
- const entityTags = e.tags.map(tag => tag.toLowerCase());
89
- if (!normalizedTags.some(tag => entityTags.includes(tag)))
90
- return false;
91
- }
92
- // Importance filter
93
- if (minImportance !== undefined && (e.importance === undefined || e.importance < minImportance)) {
94
- return false;
95
- }
96
- if (maxImportance !== undefined && (e.importance === undefined || e.importance > maxImportance)) {
97
- return false;
98
- }
99
- return true;
100
- });
82
+ // Apply tag and importance filters using SearchFilterChain
83
+ const filters = { tags, minImportance, maxImportance };
84
+ const filteredEntities = SearchFilterChain.applyFilters(graph.entities, filters);
101
85
  // Try to use pre-calculated index
102
86
  const index = await this.ensureIndexLoaded();
103
87
  const queryTerms = tokenize(query);
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Search Filter Chain
3
+ *
4
+ * Centralizes filter logic for all search implementations to eliminate
5
+ * duplicate filtering code across BasicSearch, BooleanSearch, FuzzySearch,
6
+ * and RankedSearch.
7
+ *
8
+ * @module search/SearchFilterChain
9
+ */
10
+ import { normalizeTags, hasMatchingTag } from '../utils/tagUtils.js';
11
+ import { isWithinImportanceRange } from '../utils/filterUtils.js';
12
+ import { validatePagination, applyPagination } from '../utils/paginationUtils.js';
13
+ /**
14
+ * Centralized filter chain for all search implementations.
15
+ * Ensures consistent filtering behavior across search types.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const filters: SearchFilters = { tags: ['important'], minImportance: 5 };
20
+ * const filtered = SearchFilterChain.applyFilters(entities, filters);
21
+ * const pagination = SearchFilterChain.validatePagination(0, 50);
22
+ * const result = SearchFilterChain.paginate(filtered, pagination);
23
+ * ```
24
+ */
25
+ export class SearchFilterChain {
26
+ /**
27
+ * Applies all filters to an array of entities.
28
+ * Entities must pass ALL specified filters to be included.
29
+ *
30
+ * @param entities - Entities to filter
31
+ * @param filters - Filter criteria to apply
32
+ * @returns Filtered entities array
33
+ */
34
+ static applyFilters(entities, filters) {
35
+ // Early return if no filters are active
36
+ if (!this.hasActiveFilters(filters)) {
37
+ return entities;
38
+ }
39
+ // Pre-normalize tags once for efficiency
40
+ const normalizedSearchTags = filters.tags?.length
41
+ ? normalizeTags(filters.tags)
42
+ : undefined;
43
+ return entities.filter(entity => this.entityPassesFilters(entity, filters, normalizedSearchTags));
44
+ }
45
+ /**
46
+ * Checks if an entity passes all active filters.
47
+ * Short-circuits on first failing filter for performance.
48
+ *
49
+ * @param entity - Entity to check
50
+ * @param filters - Filter criteria
51
+ * @param normalizedSearchTags - Pre-normalized search tags (for efficiency)
52
+ * @returns true if entity passes all filters
53
+ */
54
+ static entityPassesFilters(entity, filters, normalizedSearchTags) {
55
+ // Tag filter - check if entity has any matching tag
56
+ if (normalizedSearchTags && normalizedSearchTags.length > 0) {
57
+ if (!entity.tags || entity.tags.length === 0) {
58
+ return false;
59
+ }
60
+ const entityTags = normalizeTags(entity.tags);
61
+ const hasMatch = normalizedSearchTags.some(tag => entityTags.includes(tag));
62
+ if (!hasMatch) {
63
+ return false;
64
+ }
65
+ }
66
+ // Importance filter
67
+ if (!isWithinImportanceRange(entity.importance, filters.minImportance, filters.maxImportance)) {
68
+ return false;
69
+ }
70
+ // Entity type filter
71
+ if (filters.entityType && entity.entityType !== filters.entityType) {
72
+ return false;
73
+ }
74
+ // Created date filter
75
+ if (filters.createdAfter || filters.createdBefore) {
76
+ if (!entity.createdAt) {
77
+ return false;
78
+ }
79
+ const createdAt = new Date(entity.createdAt);
80
+ if (filters.createdAfter && createdAt < new Date(filters.createdAfter)) {
81
+ return false;
82
+ }
83
+ if (filters.createdBefore && createdAt > new Date(filters.createdBefore)) {
84
+ return false;
85
+ }
86
+ }
87
+ // Modified date filter
88
+ if (filters.modifiedAfter || filters.modifiedBefore) {
89
+ if (!entity.lastModified) {
90
+ return false;
91
+ }
92
+ const modifiedAt = new Date(entity.lastModified);
93
+ if (filters.modifiedAfter && modifiedAt < new Date(filters.modifiedAfter)) {
94
+ return false;
95
+ }
96
+ if (filters.modifiedBefore && modifiedAt > new Date(filters.modifiedBefore)) {
97
+ return false;
98
+ }
99
+ }
100
+ return true;
101
+ }
102
+ /**
103
+ * Checks if any filters are actually specified.
104
+ * Used for early return optimization.
105
+ *
106
+ * @param filters - Filter criteria to check
107
+ * @returns true if at least one filter is active
108
+ */
109
+ static hasActiveFilters(filters) {
110
+ return !!((filters.tags && filters.tags.length > 0) ||
111
+ filters.minImportance !== undefined ||
112
+ filters.maxImportance !== undefined ||
113
+ filters.entityType ||
114
+ filters.createdAfter ||
115
+ filters.createdBefore ||
116
+ filters.modifiedAfter ||
117
+ filters.modifiedBefore);
118
+ }
119
+ /**
120
+ * Validates and returns pagination parameters.
121
+ * Delegates to paginationUtils.validatePagination.
122
+ *
123
+ * @param offset - Starting position
124
+ * @param limit - Maximum results
125
+ * @returns Validated pagination object
126
+ */
127
+ static validatePagination(offset = 0, limit) {
128
+ return validatePagination(offset, limit);
129
+ }
130
+ /**
131
+ * Applies pagination to a filtered result set.
132
+ *
133
+ * @param entities - Entities to paginate
134
+ * @param pagination - Validated pagination parameters
135
+ * @returns Paginated slice of entities
136
+ */
137
+ static paginate(entities, pagination) {
138
+ return applyPagination(entities, pagination);
139
+ }
140
+ /**
141
+ * Convenience method to apply both filters and pagination in one call.
142
+ *
143
+ * @param entities - Entities to process
144
+ * @param filters - Filter criteria
145
+ * @param offset - Pagination offset
146
+ * @param limit - Pagination limit
147
+ * @returns Filtered and paginated entities
148
+ */
149
+ static filterAndPaginate(entities, filters, offset = 0, limit) {
150
+ const filtered = this.applyFilters(entities, filters);
151
+ const pagination = this.validatePagination(offset, limit);
152
+ return this.paginate(filtered, pagination);
153
+ }
154
+ /**
155
+ * Applies tag filter only. Useful when other filters are handled separately.
156
+ *
157
+ * @param entities - Entities to filter
158
+ * @param tags - Tags to filter by
159
+ * @returns Filtered entities
160
+ */
161
+ static filterByTags(entities, tags) {
162
+ if (!tags || tags.length === 0) {
163
+ return entities;
164
+ }
165
+ const normalizedTags = normalizeTags(tags);
166
+ return entities.filter(entity => {
167
+ if (!entity.tags || entity.tags.length === 0) {
168
+ return false;
169
+ }
170
+ return hasMatchingTag(entity.tags, normalizedTags);
171
+ });
172
+ }
173
+ /**
174
+ * Applies importance filter only. Useful when other filters are handled separately.
175
+ *
176
+ * @param entities - Entities to filter
177
+ * @param minImportance - Minimum importance
178
+ * @param maxImportance - Maximum importance
179
+ * @returns Filtered entities
180
+ */
181
+ static filterByImportance(entities, minImportance, maxImportance) {
182
+ if (minImportance === undefined && maxImportance === undefined) {
183
+ return entities;
184
+ }
185
+ return entities.filter(entity => isWithinImportanceRange(entity.importance, minImportance, maxImportance));
186
+ }
187
+ }
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Search Module Barrel Export
3
+ *
4
+ * Sprint 2: Added SearchFilterChain for centralized filter logic
3
5
  */
4
6
  export { BasicSearch } from './BasicSearch.js';
5
7
  export { RankedSearch } from './RankedSearch.js';
@@ -8,3 +10,5 @@ export { FuzzySearch } from './FuzzySearch.js';
8
10
  export { SearchSuggestions } from './SearchSuggestions.js';
9
11
  export { SavedSearchManager } from './SavedSearchManager.js';
10
12
  export { SearchManager } from './SearchManager.js';
13
+ // Sprint 2: Search Filter Chain utilities
14
+ export { SearchFilterChain } from './SearchFilterChain.js';