UNPKG

@hashgraphonline/conversational-agent

Version:

Hashgraph Online conversational AI agent implementing HCS-10 communication, HCS-2 registries, and content inscription on Hedera

652 lines (575 loc) 19.2 kB
import type { BaseMessage } from '@langchain/core/messages'; import { MemoryWindow } from './MemoryWindow'; import { ContentStorage } from './ContentStorage'; import { TokenCounter } from './TokenCounter'; /** * Entity association for storing blockchain entity contexts */ export interface EntityAssociation { /** The blockchain entity ID (e.g., tokenId, accountId, topicId) */ entityId: string; /** User-provided or derived friendly name */ entityName: string; /** Type of entity (token, account, topic, schedule, etc.) */ entityType: string; /** When the entity was created/associated */ createdAt: Date; /** Transaction ID that created this entity */ transactionId?: string; } /** * Options for resolving entity references */ export interface EntityResolutionOptions { /** Filter by specific entity type */ entityType?: string; /** Maximum number of results to return */ limit?: number; /** Whether to use fuzzy matching for natural language queries */ fuzzyMatch?: boolean; } /** * Configuration for SmartMemoryManager */ export interface SmartMemoryConfig { /** Maximum tokens for active memory window */ maxTokens?: number; /** Reserve tokens for response generation */ reserveTokens?: number; /** Model name for token counting */ modelName?: string; /** Maximum messages to store in content storage */ storageLimit?: number; } /** * Search options for history search */ export interface SearchOptions { /** Whether to perform case-sensitive search */ caseSensitive?: boolean; /** Maximum number of results to return */ limit?: number; /** Whether to use regex pattern matching */ useRegex?: boolean; } /** * Memory statistics for active memory window */ export interface MemoryStats { /** Total active messages in memory window */ totalActiveMessages: number; /** Current token count including system prompt */ currentTokenCount: number; /** Maximum token capacity */ maxTokens: number; /** Remaining token capacity */ remainingCapacity: number; /** System prompt token count */ systemPromptTokens: number; /** Memory usage percentage */ usagePercentage: number; } const IS_ENTITY_ASSOCIATION_FLAG = '"isEntityAssociation":true'; /** * TODO: investigate using chroma / rag for long term memory * Smart memory manager that combines active memory window with long-term storage * Provides context-aware memory management with automatic pruning and searchable history */ export class SmartMemoryManager { private memoryWindow: MemoryWindow; private _contentStorage: ContentStorage; private tokenCounter: TokenCounter; private config: Required<SmartMemoryConfig>; private static readonly DEFAULT_CONFIG: Required<SmartMemoryConfig> = { maxTokens: 8000, reserveTokens: 1000, modelName: 'gpt-4o', storageLimit: 1000, }; constructor(config: SmartMemoryConfig = {}) { this.config = { ...SmartMemoryManager.DEFAULT_CONFIG, ...config }; this.tokenCounter = new TokenCounter(this.config.modelName); this._contentStorage = new ContentStorage(this.config.storageLimit); this.memoryWindow = new MemoryWindow( this.config.maxTokens, this.config.reserveTokens, this.tokenCounter ); } /** * Get the content storage instance for file/content reference operations * @returns ContentStorage instance */ get contentStorage(): ContentStorage { return this._contentStorage; } /** * Add a message to the active memory window * Automatically handles pruning and storage of displaced messages * @param message - Message to add */ addMessage(message: BaseMessage): void { const result = this.memoryWindow.addMessage(message); if (result.prunedMessages.length > 0) { this._contentStorage.storeMessages(result.prunedMessages); } } /** * Get all active messages from the memory window * @returns Array of active messages in chronological order */ getMessages(): BaseMessage[] { return this.memoryWindow.getMessages(); } /** * Clear active memory window * @param clearStorage - Whether to also clear the content storage (default: false) */ clear(clearStorage: boolean = false): void { this.memoryWindow.clear(); if (clearStorage) { this._contentStorage.clear(); } } /** * Set the system prompt for the memory window * @param systemPrompt - System prompt text */ setSystemPrompt(systemPrompt: string): void { this.memoryWindow.setSystemPrompt(systemPrompt); } /** * Get the current system prompt * @returns Current system prompt text */ getSystemPrompt(): string { return this.memoryWindow.getSystemPrompt(); } /** * Search through stored message history * @param query - Search term or pattern * @param options - Search configuration * @returns Array of matching messages from history */ searchHistory(query: string, options: SearchOptions = {}): BaseMessage[] { return this._contentStorage.searchMessages(query, options); } /** * Get recent messages from storage history * @param count - Number of recent messages to retrieve * @returns Array of recent messages from storage */ getRecentHistory(count: number): BaseMessage[] { return this._contentStorage.getRecentMessages(count); } /** * Check if a message can be added without exceeding limits * @param message - Message to test * @returns True if message can be added */ canAddMessage(message: BaseMessage): boolean { return this.memoryWindow.canAddMessage(message); } /** * Get statistics about the active memory window * @returns Memory usage statistics */ getMemoryStats(): MemoryStats { const windowStats = this.memoryWindow.getStats(); return { totalActiveMessages: windowStats.totalMessages, currentTokenCount: windowStats.currentTokens, maxTokens: windowStats.maxTokens, remainingCapacity: windowStats.remainingCapacity, systemPromptTokens: windowStats.systemPromptTokens, usagePercentage: windowStats.usagePercentage, }; } /** * Get statistics about the content storage * @returns Storage usage statistics */ getStorageStats(): ReturnType<ContentStorage['getStorageStats']> { return this._contentStorage.getStorageStats(); } /** * Get combined statistics for both active memory and storage * @returns Combined memory and storage statistics */ getOverallStats(): { activeMemory: MemoryStats; storage: ReturnType<ContentStorage['getStorageStats']>; totalMessagesManaged: number; activeMemoryUtilization: number; storageUtilization: number; } { const memoryStats = this.getMemoryStats(); const storageStats = this.getStorageStats(); return { activeMemory: memoryStats, storage: storageStats, totalMessagesManaged: memoryStats.totalActiveMessages + storageStats.totalMessages, activeMemoryUtilization: memoryStats.usagePercentage, storageUtilization: storageStats.usagePercentage, }; } /** * Update the configuration and apply changes * @param newConfig - New configuration options */ updateConfig(newConfig: Partial<SmartMemoryConfig>): void { this.config = { ...this.config, ...newConfig }; if ( newConfig.maxTokens !== undefined || newConfig.reserveTokens !== undefined ) { this.memoryWindow.updateLimits( this.config.maxTokens, this.config.reserveTokens ); } if (newConfig.storageLimit !== undefined) { this._contentStorage.updateStorageLimit(this.config.storageLimit); } } /** * Get current configuration * @returns Current configuration settings */ getConfig(): Required<SmartMemoryConfig> { return { ...this.config }; } /** * Get messages from storage within a time range * @param startTime - Start of time range * @param endTime - End of time range * @returns Messages within the specified time range */ getHistoryFromTimeRange(startTime: Date, endTime: Date): BaseMessage[] { return this._contentStorage.getMessagesFromTimeRange(startTime, endTime); } /** * Get messages from storage by message type * @param messageType - Type of messages to retrieve ('human', 'ai', 'system', etc.) * @param limit - Maximum number of messages to return * @returns Messages of the specified type */ getHistoryByType(messageType: string, limit?: number): BaseMessage[] { return this._contentStorage.getMessagesByType(messageType, limit); } /** * Get recent messages from storage within the last N minutes * @param minutes - Number of minutes to look back * @returns Messages from the last N minutes */ getRecentHistoryByTime(minutes: number): BaseMessage[] { return this._contentStorage.getRecentMessagesByTime(minutes); } /** * Export the current state for persistence or analysis * @returns Serializable representation of memory state */ exportState(): { config: Required<SmartMemoryConfig>; activeMessages: Array<{ content: unknown; type: string }>; systemPrompt: string; memoryStats: MemoryStats; storageStats: ReturnType<ContentStorage['getStorageStats']>; storedMessages: ReturnType<ContentStorage['exportMessages']>; } { return { config: this.config, activeMessages: this.memoryWindow.getMessages().map((msg) => ({ content: msg.content, type: msg._getType(), })), systemPrompt: this.memoryWindow.getSystemPrompt(), memoryStats: this.getMemoryStats(), storageStats: this.getStorageStats(), storedMessages: this._contentStorage.exportMessages(), }; } /** * Get a summary of conversation context for external use * Useful for providing context to other systems or for logging * @param includeStoredContext - Whether to include recent stored messages * @returns Context summary object */ getContextSummary(includeStoredContext: boolean = false): { activeMessageCount: number; systemPrompt: string; recentMessages: BaseMessage[]; memoryUtilization: number; hasStoredHistory: boolean; recentStoredMessages?: BaseMessage[]; storageStats?: ReturnType<ContentStorage['getStorageStats']>; } { const activeMessages = this.getMessages(); const summary = { activeMessageCount: activeMessages.length, systemPrompt: this.getSystemPrompt(), recentMessages: activeMessages.slice(-5), memoryUtilization: this.getMemoryStats().usagePercentage, hasStoredHistory: this.getStorageStats().totalMessages > 0, }; if (includeStoredContext) { return { ...summary, recentStoredMessages: this.getRecentHistory(10), storageStats: this.getStorageStats(), }; } return summary; } /** * Perform maintenance operations * Optimizes storage and cleans up resources */ performMaintenance(): void {} /** * Store an entity association for later resolution * @param entityId - The blockchain entity ID * @param entityName - User-provided or derived friendly name * @param entityType - Type of entity (token, account, topic, etc.) * @param transactionId - Optional transaction ID that created this entity */ storeEntityAssociation( entityId: string, entityName: string, entityType: string, transactionId?: string ): void { try { if ( !entityId || typeof entityId !== 'string' || entityId.trim().length === 0 ) { return; } if ( !entityName || typeof entityName !== 'string' || entityName.trim().length === 0 ) { return; } if ( !entityType || typeof entityType !== 'string' || entityType.trim().length === 0 ) { return; } const sanitizedEntityId = entityId.trim(); const sanitizedEntityName = entityName.trim().substring(0, 100); const sanitizedEntityType = entityType.trim().toLowerCase(); const association: EntityAssociation & { isEntityAssociation: boolean } = { entityId: sanitizedEntityId, entityName: sanitizedEntityName, entityType: sanitizedEntityType, createdAt: new Date(), isEntityAssociation: true, ...(transactionId !== undefined && transactionId !== null && transactionId.trim() !== '' ? { transactionId: transactionId.trim() } : {}), }; const content = JSON.stringify(association); type LangChainLikeMessage = { _getType: () => string; content: unknown; id: string; name?: string; additional_kwargs?: Record<string, unknown>; }; const entityMessage: LangChainLikeMessage = { _getType: () => 'system', content: content, id: `entity_${sanitizedEntityId}_${Date.now()}`, name: 'entity_association', additional_kwargs: { entityId: sanitizedEntityId, entityName: sanitizedEntityName, entityType: sanitizedEntityType, isEntityAssociation: true, }, }; this._contentStorage.storeMessages([entityMessage as BaseMessage]); } catch (_error) {} } /** * Resolve entity references from natural language queries * @param query - Search query (entity name or natural language reference) * @param options - Resolution options for filtering and fuzzy matching * @returns Array of matching entity associations */ resolveEntityReference( query: string, options: EntityResolutionOptions = {} ): EntityAssociation[] { try { if (!query || typeof query !== 'string') { return []; } const sanitizedQuery = query.trim(); if (sanitizedQuery.length === 0) { return []; } if (sanitizedQuery.length > 200) { } const { entityType, limit = 10, fuzzyMatch = true } = options; const safeLimit = Math.max(1, Math.min(limit || 10, 100)); const isEntityIdQuery = /^0\.0\.\d+$/.test(sanitizedQuery); const searchResults = this._contentStorage.searchMessages( sanitizedQuery.substring(0, 200), { caseSensitive: false, limit: safeLimit * 2, } ); const associations: EntityAssociation[] = []; for (const message of searchResults) { try { const content = message.content as string; if ( content.includes(IS_ENTITY_ASSOCIATION_FLAG) || content.includes('entityId') ) { const parsed = JSON.parse(content); if (parsed.entityId && parsed.entityName && parsed.entityType) { if (entityType && parsed.entityType !== entityType) { continue; } if (isEntityIdQuery) { if (parsed.entityId !== sanitizedQuery) { continue; } } associations.push(parsed as EntityAssociation); } } } catch { continue; } } if (fuzzyMatch && associations.length === 0 && !isEntityIdQuery) { const fuzzyQueries = [ query.toLowerCase(), `token`, `account`, entityType || '', ].filter(Boolean); for (const fuzzyQuery of fuzzyQueries) { if (fuzzyQuery === query.toLowerCase()) continue; const fuzzyResults = this._contentStorage.searchMessages(fuzzyQuery, { caseSensitive: false, limit: limit, }); for (const message of fuzzyResults) { try { const content = message.content as string; if (content.includes(IS_ENTITY_ASSOCIATION_FLAG)) { const parsed = JSON.parse(content); if (parsed.entityId && parsed.entityName && parsed.entityType) { if (entityType && parsed.entityType !== entityType) { continue; } associations.push(parsed as EntityAssociation); } } } catch { continue; } } } } const uniqueAssociations = associations .filter( (assoc, index, arr) => arr.findIndex((a) => a.entityId === assoc.entityId) === index ) .sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); const results = uniqueAssociations.slice(0, safeLimit); return results; } catch (_error) { return []; } } /** * Get all entity associations, optionally filtered by type * @param entityType - Optional filter by entity type * @returns Array of entity associations */ getEntityAssociations(entityType?: string): EntityAssociation[] { try { const sanitizedEntityType = entityType ? entityType.trim().toLowerCase() : undefined; if ( entityType && (!sanitizedEntityType || sanitizedEntityType.length === 0) ) { return []; } const SEARCH_ANY_ENTITY = 'entityId'; const searchQuery = sanitizedEntityType || SEARCH_ANY_ENTITY; const searchResults = this._contentStorage.searchMessages(searchQuery, { caseSensitive: false, limit: 100, }); const associations: EntityAssociation[] = []; for (const message of searchResults) { try { const content = message.content as string; if (content.includes(IS_ENTITY_ASSOCIATION_FLAG)) { const parsed = JSON.parse(content); if (parsed.entityId && parsed.entityName && parsed.entityType) { if ( sanitizedEntityType && parsed.entityType !== sanitizedEntityType ) { continue; } if (parsed.createdAt && typeof parsed.createdAt === 'string') { parsed.createdAt = new Date(parsed.createdAt); } associations.push(parsed as EntityAssociation); } } } catch (_parseError) { continue; } } const results = associations .filter( (assoc, index, arr) => arr.findIndex((a) => a.entityId === assoc.entityId) === index ) .sort((a, b): number => { const getTime = (d: Date | string): number => d instanceof Date ? d.getTime() : new Date(d).getTime(); const aTime = getTime(a.createdAt); const bTime = getTime(b.createdAt); return bTime - aTime; }); return results; } catch (_error) { return []; } } /** * Clean up resources and dispose of components */ dispose(): void { this.memoryWindow.dispose(); this._contentStorage.dispose(); this.tokenCounter.dispose(); } }