@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,554 @@
1
+ /**
2
+ * Entity Manager
3
+ *
4
+ * Handles CRUD operations for entities in the knowledge graph.
5
+ *
6
+ * @module core/EntityManager
7
+ */
8
+ import { EntityNotFoundError, InvalidImportanceError, ValidationError } from '../utils/errors.js';
9
+ import { BatchCreateEntitiesSchema, UpdateEntitySchema, EntityNamesSchema } from '../utils/index.js';
10
+ import { GRAPH_LIMITS } from '../utils/constants.js';
11
+ /**
12
+ * Minimum importance value (least important).
13
+ */
14
+ export const MIN_IMPORTANCE = 0;
15
+ /**
16
+ * Maximum importance value (most important).
17
+ */
18
+ export const MAX_IMPORTANCE = 10;
19
+ /**
20
+ * Manages entity operations with automatic timestamp handling.
21
+ */
22
+ export class EntityManager {
23
+ storage;
24
+ constructor(storage) {
25
+ this.storage = storage;
26
+ }
27
+ /**
28
+ * Create multiple entities in a single batch operation.
29
+ *
30
+ * This method performs the following operations:
31
+ * - Filters out entities that already exist (duplicate names)
32
+ * - Automatically adds createdAt and lastModified timestamps
33
+ * - Normalizes all tags to lowercase for consistent searching
34
+ * - Validates importance values (must be between 0-10)
35
+ *
36
+ * @param entities - Array of entities to create. Each entity must have a unique name.
37
+ * @returns Promise resolving to array of newly created entities (excludes duplicates)
38
+ * @throws {InvalidImportanceError} If any entity has importance outside the valid range [0-10]
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const manager = new EntityManager(storage);
43
+ *
44
+ * // Create single entity
45
+ * const results = await manager.createEntities([{
46
+ * name: 'Alice',
47
+ * entityType: 'person',
48
+ * observations: ['Works as engineer', 'Lives in Seattle'],
49
+ * importance: 7,
50
+ * tags: ['Team', 'Engineering']
51
+ * }]);
52
+ *
53
+ * // Create multiple entities at once
54
+ * const users = await manager.createEntities([
55
+ * { name: 'Bob', entityType: 'person', observations: [] },
56
+ * { name: 'Charlie', entityType: 'person', observations: [] }
57
+ * ]);
58
+ * ```
59
+ */
60
+ async createEntities(entities) {
61
+ // Validate input
62
+ const validation = BatchCreateEntitiesSchema.safeParse(entities);
63
+ if (!validation.success) {
64
+ const errors = validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
65
+ throw new ValidationError('Invalid entity data', errors);
66
+ }
67
+ const graph = await this.storage.loadGraph();
68
+ const timestamp = new Date().toISOString();
69
+ // Check graph size limits
70
+ const entitiesToAdd = entities.filter(e => !graph.entities.some(existing => existing.name === e.name));
71
+ if (graph.entities.length + entitiesToAdd.length > GRAPH_LIMITS.MAX_ENTITIES) {
72
+ throw new ValidationError('Graph size limit exceeded', [`Adding ${entitiesToAdd.length} entities would exceed maximum of ${GRAPH_LIMITS.MAX_ENTITIES} entities`]);
73
+ }
74
+ const newEntities = entitiesToAdd
75
+ .map(e => {
76
+ const entity = {
77
+ ...e,
78
+ createdAt: e.createdAt || timestamp,
79
+ lastModified: e.lastModified || timestamp,
80
+ };
81
+ // Normalize tags to lowercase
82
+ if (e.tags) {
83
+ entity.tags = e.tags.map(tag => tag.toLowerCase());
84
+ }
85
+ // Validate importance
86
+ if (e.importance !== undefined) {
87
+ if (e.importance < MIN_IMPORTANCE || e.importance > MAX_IMPORTANCE) {
88
+ throw new InvalidImportanceError(e.importance, MIN_IMPORTANCE, MAX_IMPORTANCE);
89
+ }
90
+ entity.importance = e.importance;
91
+ }
92
+ return entity;
93
+ });
94
+ graph.entities.push(...newEntities);
95
+ await this.storage.saveGraph(graph);
96
+ return newEntities;
97
+ }
98
+ /**
99
+ * Delete multiple entities by name in a single batch operation.
100
+ *
101
+ * This method performs cascading deletion:
102
+ * - Removes all specified entities from the graph
103
+ * - Automatically removes all relations where these entities are source or target
104
+ * - Silently ignores entity names that don't exist (no error thrown)
105
+ *
106
+ * @param entityNames - Array of entity names to delete
107
+ * @returns Promise that resolves when deletion is complete
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * const manager = new EntityManager(storage);
112
+ *
113
+ * // Delete single entity
114
+ * await manager.deleteEntities(['Alice']);
115
+ *
116
+ * // Delete multiple entities at once
117
+ * await manager.deleteEntities(['Bob', 'Charlie', 'Dave']);
118
+ *
119
+ * // Safe to delete non-existent entities (no error)
120
+ * await manager.deleteEntities(['NonExistent']); // No error thrown
121
+ * ```
122
+ */
123
+ async deleteEntities(entityNames) {
124
+ // Validate input
125
+ const validation = EntityNamesSchema.safeParse(entityNames);
126
+ if (!validation.success) {
127
+ const errors = validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
128
+ throw new ValidationError('Invalid entity names', errors);
129
+ }
130
+ const graph = await this.storage.loadGraph();
131
+ graph.entities = graph.entities.filter(e => !entityNames.includes(e.name));
132
+ graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to));
133
+ await this.storage.saveGraph(graph);
134
+ }
135
+ /**
136
+ * Retrieve a single entity by its unique name.
137
+ *
138
+ * This is a read-only operation that does not modify the graph.
139
+ * Entity names are case-sensitive.
140
+ *
141
+ * @param name - The unique name of the entity to retrieve
142
+ * @returns Promise resolving to the Entity object if found, or null if not found
143
+ *
144
+ * @example
145
+ * ```typescript
146
+ * const manager = new EntityManager(storage);
147
+ *
148
+ * // Get an existing entity
149
+ * const alice = await manager.getEntity('Alice');
150
+ * if (alice) {
151
+ * console.log(alice.observations);
152
+ * console.log(alice.importance);
153
+ * }
154
+ *
155
+ * // Handle non-existent entity
156
+ * const missing = await manager.getEntity('NonExistent');
157
+ * console.log(missing); // null
158
+ * ```
159
+ */
160
+ async getEntity(name) {
161
+ const graph = await this.storage.loadGraph();
162
+ return graph.entities.find(e => e.name === name) || null;
163
+ }
164
+ /**
165
+ * Update one or more fields of an existing entity.
166
+ *
167
+ * This method allows partial updates - only the fields specified in the updates
168
+ * object will be changed. All other fields remain unchanged.
169
+ * The lastModified timestamp is automatically updated.
170
+ *
171
+ * @param name - The unique name of the entity to update
172
+ * @param updates - Partial entity object containing only the fields to update
173
+ * @returns Promise resolving to the fully updated Entity object
174
+ * @throws {EntityNotFoundError} If no entity with the given name exists
175
+ *
176
+ * @example
177
+ * ```typescript
178
+ * const manager = new EntityManager(storage);
179
+ *
180
+ * // Update importance only
181
+ * const updated = await manager.updateEntity('Alice', {
182
+ * importance: 9
183
+ * });
184
+ *
185
+ * // Update multiple fields
186
+ * await manager.updateEntity('Bob', {
187
+ * entityType: 'senior_engineer',
188
+ * tags: ['leadership', 'architecture'],
189
+ * observations: ['Led project X', 'Designed system Y']
190
+ * });
191
+ *
192
+ * // Add observations (requires reading existing entity first)
193
+ * const entity = await manager.getEntity('Charlie');
194
+ * if (entity) {
195
+ * await manager.updateEntity('Charlie', {
196
+ * observations: [...entity.observations, 'New observation']
197
+ * });
198
+ * }
199
+ * ```
200
+ */
201
+ async updateEntity(name, updates) {
202
+ // Validate input
203
+ const validation = UpdateEntitySchema.safeParse(updates);
204
+ if (!validation.success) {
205
+ const errors = validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
206
+ throw new ValidationError('Invalid update data', errors);
207
+ }
208
+ const graph = await this.storage.loadGraph();
209
+ const entity = graph.entities.find(e => e.name === name);
210
+ if (!entity) {
211
+ throw new EntityNotFoundError(name);
212
+ }
213
+ // Apply updates
214
+ Object.assign(entity, updates);
215
+ entity.lastModified = new Date().toISOString();
216
+ await this.storage.saveGraph(graph);
217
+ return entity;
218
+ }
219
+ /**
220
+ * Update multiple entities in a single batch operation.
221
+ *
222
+ * This method is more efficient than calling updateEntity multiple times
223
+ * as it loads and saves the graph only once. All updates are applied atomically.
224
+ * The lastModified timestamp is automatically updated for all entities.
225
+ *
226
+ * @param updates - Array of updates, each containing entity name and changes
227
+ * @returns Promise resolving to array of updated entities
228
+ * @throws {EntityNotFoundError} If any entity is not found
229
+ * @throws {ValidationError} If any update data is invalid
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * const manager = new EntityManager(storage);
234
+ *
235
+ * // Update multiple entities at once
236
+ * const updated = await manager.batchUpdate([
237
+ * { name: 'Alice', updates: { importance: 9 } },
238
+ * { name: 'Bob', updates: { importance: 8, tags: ['senior'] } },
239
+ * { name: 'Charlie', updates: { entityType: 'lead_engineer' } }
240
+ * ]);
241
+ *
242
+ * console.log(`Updated ${updated.length} entities`);
243
+ *
244
+ * // Efficiently update many entities (single graph load/save)
245
+ * const massUpdate = employees.map(name => ({
246
+ * name,
247
+ * updates: { tags: ['team-2024'] }
248
+ * }));
249
+ * await manager.batchUpdate(massUpdate);
250
+ * ```
251
+ */
252
+ async batchUpdate(updates) {
253
+ // Validate all updates first
254
+ for (const { updates: updateData } of updates) {
255
+ const validation = UpdateEntitySchema.safeParse(updateData);
256
+ if (!validation.success) {
257
+ const errors = validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
258
+ throw new ValidationError('Invalid update data', errors);
259
+ }
260
+ }
261
+ const graph = await this.storage.loadGraph();
262
+ const timestamp = new Date().toISOString();
263
+ const updatedEntities = [];
264
+ for (const { name, updates: updateData } of updates) {
265
+ const entity = graph.entities.find(e => e.name === name);
266
+ if (!entity) {
267
+ throw new EntityNotFoundError(name);
268
+ }
269
+ // Apply updates
270
+ Object.assign(entity, updateData);
271
+ entity.lastModified = timestamp;
272
+ updatedEntities.push(entity);
273
+ }
274
+ await this.storage.saveGraph(graph);
275
+ return updatedEntities;
276
+ }
277
+ /**
278
+ * Add observations to multiple entities in a single batch operation.
279
+ *
280
+ * This method performs the following operations:
281
+ * - Adds new observations to specified entities
282
+ * - Filters out duplicate observations (already present)
283
+ * - Updates lastModified timestamp only if new observations were added
284
+ *
285
+ * @param observations - Array of entity names and observations to add
286
+ * @returns Promise resolving to array of results showing which observations were added
287
+ * @throws {EntityNotFoundError} If any entity is not found
288
+ *
289
+ * @example
290
+ * ```typescript
291
+ * const manager = new EntityManager(storage);
292
+ *
293
+ * // Add observations to multiple entities
294
+ * const results = await manager.addObservations([
295
+ * { entityName: 'Alice', contents: ['Completed project X', 'Started project Y'] },
296
+ * { entityName: 'Bob', contents: ['Joined team meeting'] }
297
+ * ]);
298
+ *
299
+ * // Check what was added (duplicates are filtered out)
300
+ * results.forEach(r => {
301
+ * console.log(`${r.entityName}: added ${r.addedObservations.length} new observations`);
302
+ * });
303
+ * ```
304
+ */
305
+ async addObservations(observations) {
306
+ const graph = await this.storage.loadGraph();
307
+ const timestamp = new Date().toISOString();
308
+ const results = observations.map(o => {
309
+ const entity = graph.entities.find(e => e.name === o.entityName);
310
+ if (!entity) {
311
+ throw new EntityNotFoundError(o.entityName);
312
+ }
313
+ const newObservations = o.contents.filter(content => !entity.observations.includes(content));
314
+ entity.observations.push(...newObservations);
315
+ // Update lastModified timestamp if observations were added
316
+ if (newObservations.length > 0) {
317
+ entity.lastModified = timestamp;
318
+ }
319
+ return { entityName: o.entityName, addedObservations: newObservations };
320
+ });
321
+ await this.storage.saveGraph(graph);
322
+ return results;
323
+ }
324
+ /**
325
+ * Delete observations from multiple entities in a single batch operation.
326
+ *
327
+ * This method performs the following operations:
328
+ * - Removes specified observations from entities
329
+ * - Updates lastModified timestamp only if observations were deleted
330
+ * - Silently ignores entities that don't exist (no error thrown)
331
+ *
332
+ * @param deletions - Array of entity names and observations to delete
333
+ * @returns Promise that resolves when deletion is complete
334
+ *
335
+ * @example
336
+ * ```typescript
337
+ * const manager = new EntityManager(storage);
338
+ *
339
+ * // Delete observations from multiple entities
340
+ * await manager.deleteObservations([
341
+ * { entityName: 'Alice', observations: ['Old observation 1', 'Old observation 2'] },
342
+ * { entityName: 'Bob', observations: ['Outdated info'] }
343
+ * ]);
344
+ *
345
+ * // Safe to delete from non-existent entities (no error)
346
+ * await manager.deleteObservations([
347
+ * { entityName: 'NonExistent', observations: ['Some text'] }
348
+ * ]); // No error thrown
349
+ * ```
350
+ */
351
+ async deleteObservations(deletions) {
352
+ const graph = await this.storage.loadGraph();
353
+ const timestamp = new Date().toISOString();
354
+ deletions.forEach(d => {
355
+ const entity = graph.entities.find(e => e.name === d.entityName);
356
+ if (entity) {
357
+ const originalLength = entity.observations.length;
358
+ entity.observations = entity.observations.filter(o => !d.observations.includes(o));
359
+ // Update lastModified timestamp if observations were deleted
360
+ if (entity.observations.length < originalLength) {
361
+ entity.lastModified = timestamp;
362
+ }
363
+ }
364
+ });
365
+ await this.storage.saveGraph(graph);
366
+ }
367
+ /**
368
+ * Add tags to an entity.
369
+ *
370
+ * Tags are normalized to lowercase and duplicates are filtered out.
371
+ *
372
+ * @param entityName - Name of the entity
373
+ * @param tags - Tags to add
374
+ * @returns Result with entity name and added tags
375
+ * @throws {EntityNotFoundError} If entity is not found
376
+ */
377
+ async addTags(entityName, tags) {
378
+ const graph = await this.storage.loadGraph();
379
+ const timestamp = new Date().toISOString();
380
+ const entity = graph.entities.find(e => e.name === entityName);
381
+ if (!entity) {
382
+ throw new EntityNotFoundError(entityName);
383
+ }
384
+ // Initialize tags array if it doesn't exist
385
+ if (!entity.tags) {
386
+ entity.tags = [];
387
+ }
388
+ // Normalize tags to lowercase and filter out duplicates
389
+ const normalizedTags = tags.map(tag => tag.toLowerCase());
390
+ const newTags = normalizedTags.filter(tag => !entity.tags.includes(tag));
391
+ entity.tags.push(...newTags);
392
+ // Update lastModified timestamp if tags were added
393
+ if (newTags.length > 0) {
394
+ entity.lastModified = timestamp;
395
+ }
396
+ await this.storage.saveGraph(graph);
397
+ return { entityName, addedTags: newTags };
398
+ }
399
+ /**
400
+ * Remove tags from an entity.
401
+ *
402
+ * @param entityName - Name of the entity
403
+ * @param tags - Tags to remove
404
+ * @returns Result with entity name and removed tags
405
+ * @throws {EntityNotFoundError} If entity is not found
406
+ */
407
+ async removeTags(entityName, tags) {
408
+ const graph = await this.storage.loadGraph();
409
+ const timestamp = new Date().toISOString();
410
+ const entity = graph.entities.find(e => e.name === entityName);
411
+ if (!entity) {
412
+ throw new EntityNotFoundError(entityName);
413
+ }
414
+ if (!entity.tags) {
415
+ return { entityName, removedTags: [] };
416
+ }
417
+ // Normalize tags to lowercase
418
+ const normalizedTags = tags.map(tag => tag.toLowerCase());
419
+ const originalLength = entity.tags.length;
420
+ // Filter out the tags to remove
421
+ entity.tags = entity.tags.filter(tag => !normalizedTags.includes(tag.toLowerCase()));
422
+ const removedTags = normalizedTags.filter(tag => originalLength > entity.tags.length ||
423
+ !entity.tags.map(t => t.toLowerCase()).includes(tag));
424
+ // Update lastModified timestamp if tags were removed
425
+ if (entity.tags.length < originalLength) {
426
+ entity.lastModified = timestamp;
427
+ }
428
+ await this.storage.saveGraph(graph);
429
+ return { entityName, removedTags };
430
+ }
431
+ /**
432
+ * Set importance level for an entity.
433
+ *
434
+ * @param entityName - Name of the entity
435
+ * @param importance - Importance level (0-10)
436
+ * @returns Result with entity name and importance
437
+ * @throws {EntityNotFoundError} If entity is not found
438
+ * @throws {Error} If importance is out of range
439
+ */
440
+ async setImportance(entityName, importance) {
441
+ const graph = await this.storage.loadGraph();
442
+ const timestamp = new Date().toISOString();
443
+ // Validate importance range (0-10)
444
+ if (importance < 0 || importance > 10) {
445
+ throw new Error(`Importance must be between 0 and 10, got ${importance}`);
446
+ }
447
+ const entity = graph.entities.find(e => e.name === entityName);
448
+ if (!entity) {
449
+ throw new EntityNotFoundError(entityName);
450
+ }
451
+ entity.importance = importance;
452
+ entity.lastModified = timestamp;
453
+ await this.storage.saveGraph(graph);
454
+ return { entityName, importance };
455
+ }
456
+ /**
457
+ * Add tags to multiple entities in a single operation.
458
+ *
459
+ * @param entityNames - Names of entities to tag
460
+ * @param tags - Tags to add to each entity
461
+ * @returns Array of results showing which tags were added to each entity
462
+ */
463
+ async addTagsToMultipleEntities(entityNames, tags) {
464
+ const graph = await this.storage.loadGraph();
465
+ const timestamp = new Date().toISOString();
466
+ const normalizedTags = tags.map(tag => tag.toLowerCase());
467
+ const results = [];
468
+ for (const entityName of entityNames) {
469
+ const entity = graph.entities.find(e => e.name === entityName);
470
+ if (!entity) {
471
+ continue; // Skip non-existent entities
472
+ }
473
+ // Initialize tags array if it doesn't exist
474
+ if (!entity.tags) {
475
+ entity.tags = [];
476
+ }
477
+ // Filter out duplicates
478
+ const newTags = normalizedTags.filter(tag => !entity.tags.includes(tag));
479
+ entity.tags.push(...newTags);
480
+ // Update lastModified timestamp if tags were added
481
+ if (newTags.length > 0) {
482
+ entity.lastModified = timestamp;
483
+ }
484
+ results.push({ entityName, addedTags: newTags });
485
+ }
486
+ await this.storage.saveGraph(graph);
487
+ return results;
488
+ }
489
+ /**
490
+ * Replace a tag with a new tag across all entities (rename tag).
491
+ *
492
+ * @param oldTag - Tag to replace
493
+ * @param newTag - New tag value
494
+ * @returns Result with affected entities and count
495
+ */
496
+ async replaceTag(oldTag, newTag) {
497
+ const graph = await this.storage.loadGraph();
498
+ const timestamp = new Date().toISOString();
499
+ const normalizedOldTag = oldTag.toLowerCase();
500
+ const normalizedNewTag = newTag.toLowerCase();
501
+ const affectedEntities = [];
502
+ for (const entity of graph.entities) {
503
+ if (!entity.tags || !entity.tags.includes(normalizedOldTag)) {
504
+ continue;
505
+ }
506
+ // Replace old tag with new tag
507
+ const index = entity.tags.indexOf(normalizedOldTag);
508
+ entity.tags[index] = normalizedNewTag;
509
+ entity.lastModified = timestamp;
510
+ affectedEntities.push(entity.name);
511
+ }
512
+ await this.storage.saveGraph(graph);
513
+ return { affectedEntities, count: affectedEntities.length };
514
+ }
515
+ /**
516
+ * Merge two tags into one target tag across all entities.
517
+ *
518
+ * Combines tag1 and tag2 into targetTag. Any entity with either tag1 or tag2
519
+ * will have both removed and targetTag added (if not already present).
520
+ *
521
+ * @param tag1 - First tag to merge
522
+ * @param tag2 - Second tag to merge
523
+ * @param targetTag - Target tag to merge into
524
+ * @returns Object with affected entity names and count
525
+ */
526
+ async mergeTags(tag1, tag2, targetTag) {
527
+ const graph = await this.storage.loadGraph();
528
+ const timestamp = new Date().toISOString();
529
+ const normalizedTag1 = tag1.toLowerCase();
530
+ const normalizedTag2 = tag2.toLowerCase();
531
+ const normalizedTargetTag = targetTag.toLowerCase();
532
+ const affectedEntities = [];
533
+ for (const entity of graph.entities) {
534
+ if (!entity.tags) {
535
+ continue;
536
+ }
537
+ const hasTag1 = entity.tags.includes(normalizedTag1);
538
+ const hasTag2 = entity.tags.includes(normalizedTag2);
539
+ if (!hasTag1 && !hasTag2) {
540
+ continue;
541
+ }
542
+ // Remove both tags
543
+ entity.tags = entity.tags.filter(tag => tag !== normalizedTag1 && tag !== normalizedTag2);
544
+ // Add target tag if not already present
545
+ if (!entity.tags.includes(normalizedTargetTag)) {
546
+ entity.tags.push(normalizedTargetTag);
547
+ }
548
+ entity.lastModified = timestamp;
549
+ affectedEntities.push(entity.name);
550
+ }
551
+ await this.storage.saveGraph(graph);
552
+ return { affectedEntities, count: affectedEntities.length };
553
+ }
554
+ }