UNPKG

crewai-ts

Version:

TypeScript port of crewAI for agent-based workflows

538 lines (537 loc) 20.9 kB
/** * UserMemory Class * * A specialized memory implementation for storing user-specific information * and preferences with optimized retrieval. Designed to maintain context * across conversations and interactions while minimizing memory usage. */ import { nanoid } from 'nanoid'; /** * UserMemory: Specialized memory system for storing and retrieving * user-specific information with optimized performance. * * Features: * - Memory-efficient storage of user preferences and history * - Fast indexed retrieval for common user attributes * - Automatic memory management with LRU eviction * - Optional content compression */ export class UserMemory { items; userIndex; // userid -> memory item ids attributeIndex; // attribute -> value -> item ids lruOrder; // Most recently used item ids (for eviction) options; /** * Create a new UserMemory instance with optimized data structures */ constructor(options = {}) { // Initialize with default options optimized for performance this.options = { maxSize: options.maxSize || 1000, enableIndexing: options.enableIndexing !== undefined ? options.enableIndexing : true, expirationMs: options.expirationMs || 0, useCompression: options.useCompression || false, serializer: options.serializer || JSON.stringify, deserializer: options.deserializer || JSON.parse }; // Use optimized data structures for memory operations this.items = new Map(); // O(1) lookup by id this.userIndex = new Map(); // O(1) lookup by userId this.attributeIndex = new Map(); // O(1) attribute lookup this.lruOrder = []; // Track usage for LRU eviction // Pre-allocate common attribute indexes for even faster retrieval if (this.options.enableIndexing) { ['userId', 'username', 'preference', 'history', 'interaction'].forEach(attr => { this.attributeIndex.set(attr, new Map()); }); } } /** * Add a memory item with optimized indexing * @param content The content to store * @param metadata Additional metadata about the content * @returns The created memory item */ async add(content, metadata) { // Apply compression if enabled for memory efficiency const finalContent = this.options.useCompression ? this.compressContent(content) : content; // Create a new memory item with optimized structure const now = Date.now(); const item = { id: nanoid(), content: finalContent, metadata: metadata || {}, createdAt: now, lastAccessedAt: now }; // Check if we need to evict items due to size limit if (this.items.size >= this.options.maxSize) { this.evictLeastRecentlyUsed(); } // Store the item and update indexes this.items.set(item.id, item); this.lruOrder.push(item.id); // Add to end of LRU list (most recently used) // Efficiently index the item if indexing is enabled if (this.options.enableIndexing && metadata) { // Index by userId if present const userId = metadata.userId || metadata.user_id; if (userId) { if (!this.userIndex.has(userId)) { this.userIndex.set(userId, new Set()); } this.userIndex.get(userId).add(item.id); } // Index other attributes for optimized retrieval Object.entries(metadata).forEach(([key, value]) => { // Skip indexing large values or complex objects to save memory if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { return; } const valueStr = String(value); if (!this.attributeIndex.has(key)) { this.attributeIndex.set(key, new Map()); } const keyIndex = this.attributeIndex.get(key); if (!keyIndex.has(valueStr)) { keyIndex.set(valueStr, new Set()); } keyIndex.get(valueStr).add(item.id); }); } return item; } /** * Search for memory items with optimized retrieval algorithms * @param params Search parameters * @returns Search results with relevance scores */ async search(params) { const { query, metadata, limit = 10, offset = 0, minRelevance = 0 } = params; const results = []; const matchingIds = new Set(); const now = Date.now(); // Optimize search strategy based on provided parameters if (metadata && this.options.enableIndexing) { // Most efficient path: Use indexed lookup for metadata // First check userId for most common case const userId = metadata.userId || metadata.user_id; if (userId && this.userIndex.has(userId)) { // Get all items for this user const userItems = this.userIndex.get(userId); userItems.forEach(id => matchingIds.add(id)); } // Filter further by other metadata if specified let hasOtherMetadata = false; Object.entries(metadata).forEach(([key, value]) => { // Skip userId as we've already handled it if (key === 'userId' || key === 'user_id') return; hasOtherMetadata = true; if (!this.attributeIndex.has(key)) return; const valueStr = String(value); const keyIndex = this.attributeIndex.get(key); if (!keyIndex.has(valueStr)) return; const matchingItemsForAttr = keyIndex.get(valueStr); // If this is our first metadata filter besides userId, use all matching items if (matchingIds.size === 0 || (userId && matchingIds.size === this.userIndex.get(userId).size)) { matchingItemsForAttr.forEach(id => matchingIds.add(id)); } else { // Otherwise, perform an intersection to narrow results const intersection = new Set(); for (const id of matchingIds) { if (matchingItemsForAttr.has(id)) { intersection.add(id); } } // Update matchingIds to the intersection matchingIds.clear(); intersection.forEach(id => matchingIds.add(id)); } }); // If no metadata matched or no specific metadata was provided besides userId // then we either have all items for a userId or need to search all items if (matchingIds.size === 0 && !hasOtherMetadata && !userId) { // No indexed metadata matched, fall back to searching all items this.items.forEach((item, id) => { // Skip expired items if (this.isExpired(item, now)) return; matchingIds.add(id); }); } } else { // No metadata or indexing disabled - search all items this.items.forEach((item, id) => { // Skip expired items if (this.isExpired(item, now)) return; matchingIds.add(id); }); } // Apply text search if query is specified if (query && query.trim() !== '') { const searchTerms = query.toLowerCase().split(/\s+/); const filteredIds = new Set(); for (const id of matchingIds) { const item = this.items.get(id); const content = this.options.useCompression ? this.decompressContent(item.content) : item.content; // Simple relevance scoring algorithm (can be extended with more sophisticated approaches) let relevanceScore = 0; const contentLower = content.toLowerCase(); // Score based on exact match and term frequency for (const term of searchTerms) { if (contentLower.includes(term)) { // Count occurrences for term frequency scoring const count = (contentLower.match(new RegExp(term, 'g')) || []).length; relevanceScore += 0.5 + (count * 0.1); // Base score + frequency bonus } } // Bonus for exact phrase match if (contentLower.includes(query.toLowerCase())) { relevanceScore += 1.0; } // Add recency bias (more recent items score higher) if (item.lastAccessedAt) { const recencyScore = Math.min(0.5, (now - item.createdAt) / (1000 * 60 * 60 * 24 * 30)); relevanceScore += recencyScore; } if (relevanceScore >= minRelevance) { // Update the item with the relevance score const scoredItem = { ...item, relevanceScore }; this.items.set(id, scoredItem); filteredIds.add(id); } } // Update matching IDs to only those that match the text search matchingIds.clear(); filteredIds.forEach(id => matchingIds.add(id)); } // Convert the set of IDs to an array of items const matchingItems = Array.from(matchingIds).map(id => this.items.get(id)); // Sort by relevance score (if available) or recency matchingItems.sort((a, b) => { if (a.relevanceScore !== undefined && b.relevanceScore !== undefined) { return b.relevanceScore - a.relevanceScore; // Higher scores first } return (b.lastAccessedAt || b.createdAt) - (a.lastAccessedAt || a.createdAt); // Most recent first }); // Apply pagination const paginatedItems = matchingItems.slice(offset, offset + limit); // Update access times for retrieved items paginatedItems.forEach(item => { this.updateAccessTime(item.id); }); return { items: paginatedItems, total: matchingItems.length }; } /** * Get a specific memory item by ID * @param id The ID of the item to retrieve * @returns The memory item or null if not found */ async get(id) { const item = this.items.get(id); if (!item) return null; // Check if the item has expired if (this.isExpired(item)) { this.remove(id); // Clean up expired items return null; } // Update access time (for LRU tracking) this.updateAccessTime(id); // If compression is enabled, decompress before returning if (this.options.useCompression) { return { ...item, content: this.decompressContent(item.content) }; } return item; } /** * Update an existing memory item * @param id The ID of the item to update * @param updates The updates to apply * @returns The updated item or null if not found */ async update(id, updates) { const item = this.items.get(id); if (!item) return null; // Check if the item has expired if (this.isExpired(item)) { this.remove(id); // Clean up expired items return null; } // Update the item in memory const updatedItem = { ...item, ...updates, lastAccessedAt: Date.now() }; // If updating content and compression is enabled, ensure it's compressed if (updates.content !== undefined && this.options.useCompression) { updatedItem.content = this.compressContent(updates.content); } // Update metadata indexes if metadata is changing and indexing is enabled if (updates.metadata && this.options.enableIndexing) { // Remove from existing metadata indexes this.removeFromMetadataIndexes(id, item.metadata || {}); // Add to new metadata indexes this.addToMetadataIndexes(id, updates.metadata); } // Update the item this.items.set(id, updatedItem); this.updateAccessTime(id); // If compression is enabled, decompress before returning if (this.options.useCompression) { return { ...updatedItem, content: this.decompressContent(updatedItem.content) }; } return updatedItem; } /** * Remove an item from memory * @param id The ID of the item to remove * @returns Whether the item was removed */ async remove(id) { const item = this.items.get(id); if (!item) return false; // Remove from all indexes if indexing is enabled if (this.options.enableIndexing && item.metadata) { this.removeFromMetadataIndexes(id, item.metadata); } // Remove from LRU tracking const lruIndex = this.lruOrder.indexOf(id); if (lruIndex !== -1) { this.lruOrder.splice(lruIndex, 1); } // Remove the item itself this.items.delete(id); return true; } /** * Clear all items from memory */ async clear() { this.items.clear(); this.userIndex.clear(); this.attributeIndex.clear(); this.lruOrder = []; // Reinitialize indexes if needed if (this.options.enableIndexing) { ['userId', 'username', 'preference', 'history', 'interaction'].forEach(attr => { this.attributeIndex.set(attr, new Map()); }); } } /** * Reset the memory (clear and reinitialize) */ async reset() { await this.clear(); } /** * Get all memory items for a specific user * @param userId The user ID to get items for * @param limit Maximum number of items to return * @returns Memory items for the user */ async getUserItems(userId, limit = 100) { return this.search({ metadata: { userId }, limit }); } /** * Get user preferences stored in memory * @param userId The user ID to get preferences for * @returns Object containing user preferences */ async getUserPreferences(userId) { const result = await this.search({ metadata: { userId, type: 'preference' }, limit: 100 }); // Combine all preference items into a single object const preferences = {}; for (const item of result.items) { try { const prefData = this.options.deserializer(item.content); Object.assign(preferences, prefData); } catch (e) { // Skip invalid preference data console.warn('Failed to parse preference data:', e); } } return preferences; } /** * Store a user preference * @param userId The user ID to store the preference for * @param key The preference key * @param value The preference value */ async setUserPreference(userId, key, value) { const prefData = { [key]: value }; const content = this.options.serializer(prefData); await this.add(content, { userId, type: 'preference', key }); } /** * Add a user interaction to memory * @param userId The user ID * @param interaction The interaction details */ async addUserInteraction(userId, interaction) { const content = this.options.serializer(interaction); await this.add(content, { userId, type: 'interaction', timestamp: Date.now(), ...interaction }); } /** * Helper: Check if a memory item has expired * @private */ isExpired(item, currentTime = Date.now()) { if (this.options.expirationMs === 0) return false; // No expiration const lastAccess = item.lastAccessedAt || item.createdAt; return currentTime - lastAccess > this.options.expirationMs; } /** * Helper: Update the access time for an item * @private */ updateAccessTime(id) { const item = this.items.get(id); if (!item) return; // Update the last accessed time item.lastAccessedAt = Date.now(); this.items.set(id, item); // Update LRU ordering const lruIndex = this.lruOrder.indexOf(id); if (lruIndex !== -1) { this.lruOrder.splice(lruIndex, 1); } this.lruOrder.push(id); // Move to end (most recently used) } /** * Helper: Evict the least recently used item when max size is reached * @private */ evictLeastRecentlyUsed() { if (this.lruOrder.length === 0) return; const oldestId = this.lruOrder.shift(); if (oldestId) { const item = this.items.get(oldestId); if (item && item.metadata && this.options.enableIndexing) { this.removeFromMetadataIndexes(oldestId, item.metadata); } this.items.delete(oldestId); } } /** * Helper: Remove an item from metadata indexes * @private */ removeFromMetadataIndexes(id, metadata) { // Check for userId specifically for the user index const userId = metadata.userId || metadata.user_id; if (userId && this.userIndex.has(userId)) { const userItems = this.userIndex.get(userId); userItems.delete(id); if (userItems.size === 0) { this.userIndex.delete(userId); } } // Remove from attribute indexes Object.entries(metadata).forEach(([key, value]) => { if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { return; } const valueStr = String(value); if (this.attributeIndex.has(key)) { const keyIndex = this.attributeIndex.get(key); if (keyIndex.has(valueStr)) { const itemsWithValue = keyIndex.get(valueStr); itemsWithValue.delete(id); if (itemsWithValue.size === 0) { keyIndex.delete(valueStr); } } } }); } /** * Helper: Add an item to metadata indexes * @private */ addToMetadataIndexes(id, metadata) { // Check for userId specifically for the user index const userId = metadata.userId || metadata.user_id; if (userId) { if (!this.userIndex.has(userId)) { this.userIndex.set(userId, new Set()); } this.userIndex.get(userId).add(id); } // Add to attribute indexes Object.entries(metadata).forEach(([key, value]) => { if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { return; } const valueStr = String(value); if (!this.attributeIndex.has(key)) { this.attributeIndex.set(key, new Map()); } const keyIndex = this.attributeIndex.get(key); if (!keyIndex.has(valueStr)) { keyIndex.set(valueStr, new Set()); } keyIndex.get(valueStr).add(id); }); } /** * Helper: Apply simple compression to content * @private */ compressContent(content) { // In a real implementation, use a proper compression algorithm // For now, we'll use a simple base64 encoding as a placeholder return Buffer.from(content).toString('base64'); } /** * Helper: Decompress content * @private */ decompressContent(compressedContent) { // Matching the simple compression above return Buffer.from(compressedContent, 'base64').toString('utf8'); } }