UNPKG

@hashgraphonline/conversational-agent

Version:

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

901 lines (766 loc) 27 kB
import type { BaseMessage } from '@langchain/core/messages'; import { ReferenceIdGenerator } from './ReferenceIdGenerator'; import { ReferenceId, ContentReference, ContentMetadata, ReferenceResolutionResult, ContentReferenceConfig, ContentReferenceStore, ContentReferenceStats, ContentReferenceError, ContentType, ContentSource, ReferenceLifecycleState, DEFAULT_CONTENT_REFERENCE_CONFIG } from '../types/content-reference'; /** * Stored message with metadata */ interface StoredMessage { message: BaseMessage; storedAt: Date; id: string; } /** * Search options for message queries */ 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; } /** * Result of storing messages */ interface StoreResult { /** Number of messages successfully stored */ stored: number; /** Number of old messages dropped to make room */ dropped: number; } /** * Storage statistics */ export interface StorageStats { /** Total number of messages currently stored */ totalMessages: number; /** Maximum storage capacity */ maxStorageLimit: number; /** Percentage of storage used */ usagePercentage: number; /** Timestamp of oldest message */ oldestMessageTime: Date | undefined; /** Timestamp of newest message */ newestMessageTime: Date | undefined; } /** * Stored content with reference metadata */ interface StoredContent { /** The actual content buffer */ content: Buffer; /** Complete metadata */ metadata: ContentMetadata; /** Current lifecycle state */ state: ReferenceLifecycleState; /** When this reference expires (if applicable) */ expiresAt?: Date; } /** * Content storage for managing pruned conversation messages and large content references * Provides searchable storage with time-based querying and automatic cleanup. * * Extended to support reference-based storage for large content to optimize context window usage. */ export class ContentStorage implements ContentReferenceStore { private messages: StoredMessage[] = []; private maxStorage: number; private idCounter: number = 0; private contentStore: Map<ReferenceId, StoredContent> = new Map(); private referenceConfig: ContentReferenceConfig; private cleanupTimer?: NodeJS.Timeout; private referenceStats: Omit<ContentReferenceStats, 'performanceMetrics'> & { performanceMetrics: ContentReferenceStats['performanceMetrics'] & { creationTimes: number[]; resolutionTimes: number[]; cleanupTimes: number[]; }; }; public static readonly DEFAULT_MAX_STORAGE = 1000; constructor( maxStorage: number = ContentStorage.DEFAULT_MAX_STORAGE, referenceConfig?: Partial<ContentReferenceConfig> ) { this.maxStorage = maxStorage; this.referenceConfig = { ...DEFAULT_CONTENT_REFERENCE_CONFIG, ...referenceConfig }; this.referenceStats = { activeReferences: 0, totalStorageBytes: 0, recentlyCleanedUp: 0, totalResolutions: 0, failedResolutions: 0, averageContentSize: 0, storageUtilization: 0, performanceMetrics: { averageCreationTimeMs: 0, averageResolutionTimeMs: 0, averageCleanupTimeMs: 0, creationTimes: [], resolutionTimes: [], cleanupTimes: [] } }; if (this.referenceConfig.enableAutoCleanup) { this.startReferenceCleanupTimer(); } } /** * Store messages in the content storage * Automatically drops oldest messages if storage limit is exceeded * @param messages - Messages to store * @returns Result indicating how many messages were stored and dropped */ storeMessages(messages: BaseMessage[]): StoreResult { if (messages.length === 0) { return { stored: 0, dropped: 0 }; } const now = new Date(); let dropped = 0; const storedMessages: StoredMessage[] = messages.map(message => ({ message, storedAt: now, id: this.generateId() })); this.messages.push(...storedMessages); while (this.messages.length > this.maxStorage) { this.messages.shift(); dropped++; } return { stored: storedMessages.length, dropped }; } /** * Get the most recent messages from storage * @param count - Number of recent messages to retrieve * @returns Array of recent messages in chronological order */ getRecentMessages(count: number): BaseMessage[] { if (count <= 0 || this.messages.length === 0) { return []; } const startIndex = Math.max(0, this.messages.length - count); return this.messages .slice(startIndex) .map(stored => stored.message); } /** * Search for messages containing specific text or patterns * @param query - Search term or regex pattern * @param options - Search configuration options * @returns Array of matching messages */ searchMessages(query: string, options: SearchOptions = {}): BaseMessage[] { if (!query || this.messages.length === 0) { return []; } const { caseSensitive = false, limit, useRegex = false } = options; let matches: BaseMessage[] = []; if (useRegex) { try { const regex = new RegExp(query, caseSensitive ? 'g' : 'gi'); matches = this.messages .filter(stored => regex.test(stored.message.content as string)) .map(stored => stored.message); } catch (error) { return []; } } else { const searchTerm = caseSensitive ? query : query.toLowerCase(); matches = this.messages .filter(stored => { const content = stored.message.content as string; const searchContent = caseSensitive ? content : content.toLowerCase(); return searchContent.includes(searchTerm); }) .map(stored => stored.message); } return limit ? matches.slice(0, limit) : matches; } /** * Get messages from a specific time range * @param startTime - Start of time range (inclusive) * @param endTime - End of time range (inclusive) * @returns Array of messages within the time range */ getMessagesFromTimeRange(startTime: Date, endTime: Date): BaseMessage[] { if (startTime > endTime || this.messages.length === 0) { return []; } return this.messages .filter(stored => stored.storedAt >= startTime && stored.storedAt <= endTime ) .map(stored => stored.message); } /** * Get storage statistics and usage information * @returns Current storage statistics */ getStorageStats(): StorageStats { const totalMessages = this.messages.length; const usagePercentage = totalMessages > 0 ? Math.round((totalMessages / this.maxStorage) * 100) : 0; let oldestMessageTime: Date | undefined; let newestMessageTime: Date | undefined; if (totalMessages > 0) { oldestMessageTime = this.messages[0].storedAt; newestMessageTime = this.messages[totalMessages - 1].storedAt; } return { totalMessages, maxStorageLimit: this.maxStorage, usagePercentage, oldestMessageTime, newestMessageTime }; } /** * Clear all stored messages */ clear(): void { this.messages = []; this.idCounter = 0; } /** * Get total number of stored messages * @returns Number of messages currently in storage */ getTotalStoredMessages(): number { return this.messages.length; } /** * Update the maximum storage limit * @param newLimit - New maximum storage limit */ updateStorageLimit(newLimit: number): void { if (newLimit <= 0) { throw new Error('Storage limit must be greater than 0'); } this.maxStorage = newLimit; while (this.messages.length > this.maxStorage) { this.messages.shift(); } } /** * Get messages by message type * @param messageType - Type of messages to retrieve ('human', 'ai', 'system', etc.) * @param limit - Maximum number of messages to return * @returns Array of messages of the specified type */ getMessagesByType(messageType: string, limit?: number): BaseMessage[] { const filtered = this.messages .filter(stored => stored.message._getType() === messageType) .map(stored => stored.message); return limit ? filtered.slice(0, limit) : filtered; } /** * Get the current storage configuration * @returns Storage configuration object */ getConfig() { return { maxStorage: this.maxStorage, currentUsage: this.messages.length, utilizationPercentage: (this.messages.length / this.maxStorage) * 100 }; } /** * Generate a unique ID for stored messages * @returns Unique string identifier */ private generateId(): string { return `msg_${++this.idCounter}_${Date.now()}`; } /** * Get messages stored within the last N minutes * @param minutes - Number of minutes to look back * @returns Array of messages from the last N minutes */ getRecentMessagesByTime(minutes: number): BaseMessage[] { if (minutes <= 0 || this.messages.length === 0) { return []; } const cutoffTime = new Date(Date.now() - (minutes * 60 * 1000)); return this.messages .filter(stored => stored.storedAt >= cutoffTime) .map(stored => stored.message); } /** * Export messages to a JSON-serializable format * @returns Serializable representation of stored messages */ exportMessages() { return this.messages.map(stored => ({ content: stored.message.content, type: stored.message._getType(), storedAt: stored.storedAt.toISOString(), id: stored.id })); } /** * Determine if content should be stored as a reference based on size */ shouldUseReference(content: Buffer | string): boolean { const size = Buffer.isBuffer(content) ? content.length : Buffer.byteLength(content, 'utf8'); return size > this.referenceConfig.sizeThresholdBytes; } /** * Store content and return a reference if it exceeds the size threshold * Otherwise returns null to indicate direct content should be used */ async storeContentIfLarge( content: Buffer | string, metadata: { contentType?: ContentType; mimeType?: string; source: ContentSource; mcpToolName?: string; fileName?: string; tags?: string[]; customMetadata?: Record<string, unknown>; } ): Promise<ContentReference | null> { const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'); if (!this.shouldUseReference(buffer)) { return null; } const storeMetadata: Omit<ContentMetadata, 'createdAt' | 'lastAccessedAt' | 'accessCount'> = { contentType: metadata.contentType || this.detectContentType(buffer, metadata.mimeType), sizeBytes: buffer.length, source: metadata.source, tags: [] }; if (metadata.mimeType !== undefined) { storeMetadata.mimeType = metadata.mimeType; } if (metadata.mcpToolName !== undefined) { storeMetadata.mcpToolName = metadata.mcpToolName; } if (metadata.fileName !== undefined) { storeMetadata.fileName = metadata.fileName; } if (metadata.tags !== undefined) { storeMetadata.tags = metadata.tags; } if (metadata.customMetadata !== undefined) { storeMetadata.customMetadata = metadata.customMetadata; } return await this.storeContent(buffer, storeMetadata); } /** * Store content and return a reference (implements ContentReferenceStore) */ async storeContent( content: Buffer, metadata: Omit<ContentMetadata, 'createdAt' | 'lastAccessedAt' | 'accessCount'> ): Promise<ContentReference> { const startTime = Date.now(); try { const now = new Date(); const referenceId = ReferenceIdGenerator.generateId(content); const fullMetadata: ContentMetadata = { ...metadata, createdAt: now, lastAccessedAt: now, accessCount: 0 }; const storedContent: StoredContent = { content, metadata: fullMetadata, state: 'active' }; const expirationTime = this.calculateExpirationTime(metadata.source); if (expirationTime !== undefined) { storedContent.expiresAt = expirationTime; } this.contentStore.set(referenceId, storedContent); this.updateStatsAfterStore(content.length); await this.enforceReferenceStorageLimits(); const preview = this.createContentPreview(content, fullMetadata.contentType); const referenceMetadata: Pick<ContentMetadata, 'contentType' | 'sizeBytes' | 'source' | 'fileName' | 'mimeType'> = { contentType: fullMetadata.contentType, sizeBytes: fullMetadata.sizeBytes, source: fullMetadata.source }; if (fullMetadata.fileName !== undefined) { referenceMetadata.fileName = fullMetadata.fileName; } if (fullMetadata.mimeType !== undefined) { referenceMetadata.mimeType = fullMetadata.mimeType; } const reference: ContentReference = { referenceId, state: 'active', preview, metadata: referenceMetadata, createdAt: now, format: 'ref://{id}' as const }; const duration = Date.now() - startTime; this.recordPerformanceMetric('creation', duration); return reference; } catch (error) { const duration = Date.now() - startTime; this.recordPerformanceMetric('creation', duration); throw new ContentReferenceError( `Failed to store content: ${error instanceof Error ? error.message : 'Unknown error'}`, 'system_error', undefined, ['Try again', 'Check storage limits', 'Contact administrator'] ); } } /** * Resolve a reference to its content (implements ContentReferenceStore) */ async resolveReference(referenceId: ReferenceId): Promise<ReferenceResolutionResult> { const startTime = Date.now(); try { if (!ReferenceIdGenerator.isValidReferenceId(referenceId)) { this.referenceStats.failedResolutions++; return { success: false, error: 'Invalid reference ID format', errorType: 'not_found', suggestedActions: ['Check the reference ID format', 'Ensure the reference ID is complete'] }; } const storedContent = this.contentStore.get(referenceId); if (!storedContent) { this.referenceStats.failedResolutions++; return { success: false, error: 'Reference not found', errorType: 'not_found', suggestedActions: ['Verify the reference ID', 'Check if the content has expired', 'Request fresh content'] }; } if (storedContent.expiresAt && storedContent.expiresAt < new Date()) { storedContent.state = 'expired'; this.referenceStats.failedResolutions++; return { success: false, error: 'Reference has expired', errorType: 'expired', suggestedActions: ['Request fresh content', 'Use alternative content source'] }; } if (storedContent.state !== 'active') { this.referenceStats.failedResolutions++; return { success: false, error: `Reference is ${storedContent.state}`, errorType: storedContent.state === 'expired' ? 'expired' : 'corrupted', suggestedActions: ['Request fresh content', 'Check reference validity'] }; } storedContent.metadata.lastAccessedAt = new Date(); storedContent.metadata.accessCount++; this.referenceStats.totalResolutions++; const duration = Date.now() - startTime; this.recordPerformanceMetric('resolution', duration); return { success: true, content: storedContent.content, metadata: storedContent.metadata }; } catch (error) { const duration = Date.now() - startTime; this.recordPerformanceMetric('resolution', duration); this.referenceStats.failedResolutions++; return { success: false, error: `System error resolving reference: ${error instanceof Error ? error.message : 'Unknown error'}`, errorType: 'system_error', suggestedActions: ['Try again', 'Contact administrator'] }; } } /** * Check if a reference exists and is valid */ async hasReference(referenceId: ReferenceId): Promise<boolean> { if (!ReferenceIdGenerator.isValidReferenceId(referenceId)) { return false; } const storedContent = this.contentStore.get(referenceId); if (!storedContent) { return false; } if (storedContent.expiresAt && storedContent.expiresAt < new Date()) { storedContent.state = 'expired'; return false; } return storedContent.state === 'active'; } /** * Mark a reference for cleanup */ async cleanupReference(referenceId: ReferenceId): Promise<boolean> { const storedContent = this.contentStore.get(referenceId); if (!storedContent) { return false; } this.referenceStats.totalStorageBytes -= storedContent.content.length; this.referenceStats.activeReferences--; this.referenceStats.recentlyCleanedUp++; this.contentStore.delete(referenceId); return true; } /** * Get current reference storage statistics (implements ContentReferenceStore) */ async getStats(): Promise<ContentReferenceStats> { this.updateReferenceStorageStats(); return { ...this.referenceStats, performanceMetrics: { averageCreationTimeMs: this.calculateAverage(this.referenceStats.performanceMetrics.creationTimes), averageResolutionTimeMs: this.calculateAverage(this.referenceStats.performanceMetrics.resolutionTimes), averageCleanupTimeMs: this.calculateAverage(this.referenceStats.performanceMetrics.cleanupTimes) } }; } /** * Update reference configuration */ async updateConfig(config: Partial<ContentReferenceConfig>): Promise<void> { this.referenceConfig = { ...this.referenceConfig, ...config }; if (this.cleanupTimer) { clearInterval(this.cleanupTimer); delete this.cleanupTimer; } if (this.referenceConfig.enableAutoCleanup) { this.startReferenceCleanupTimer(); } } /** * Perform cleanup based on current policies (implements ContentReferenceStore) */ async performCleanup(): Promise<{ cleanedUp: number; errors: string[] }> { const startTime = Date.now(); const errors: string[] = []; let cleanedUp = 0; try { const now = new Date(); const toCleanup: ReferenceId[] = []; for (const [referenceId, storedContent] of this.contentStore.entries()) { let shouldCleanup = false; if (storedContent.expiresAt && storedContent.expiresAt < now) { shouldCleanup = true; storedContent.state = 'expired'; } const ageMs = now.getTime() - storedContent.metadata.createdAt.getTime(); const policy = this.getCleanupPolicy(storedContent.metadata.source); if (ageMs > policy.maxAgeMs) { shouldCleanup = true; } if (storedContent.state === 'cleanup_pending') { shouldCleanup = true; } if (shouldCleanup) { toCleanup.push(referenceId); } } toCleanup.sort((a, b) => { const aContent = this.contentStore.get(a)!; const bContent = this.contentStore.get(b)!; const aPriority = this.getCleanupPolicy(aContent.metadata.source).priority; const bPriority = this.getCleanupPolicy(bContent.metadata.source).priority; return bPriority - aPriority; }); for (const referenceId of toCleanup) { try { const success = await this.cleanupReference(referenceId); if (success) { cleanedUp++; } } catch (error) { errors.push(`Failed to cleanup ${referenceId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (this.contentStore.size > this.referenceConfig.maxReferences) { const sortedByAge = Array.from(this.contentStore.entries()) .sort(([, a], [, b]) => a.metadata.lastAccessedAt.getTime() - b.metadata.lastAccessedAt.getTime()); const excessCount = this.contentStore.size - this.referenceConfig.maxReferences; for (let i = 0; i < excessCount && i < sortedByAge.length; i++) { const [referenceId] = sortedByAge[i]; try { const success = await this.cleanupReference(referenceId); if (success) { cleanedUp++; } } catch (error) { errors.push(`Failed to cleanup excess reference ${referenceId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } const duration = Date.now() - startTime; this.recordPerformanceMetric('cleanup', duration); return { cleanedUp, errors }; } catch (error) { const duration = Date.now() - startTime; this.recordPerformanceMetric('cleanup', duration); const errorMessage = `Cleanup process failed: ${error instanceof Error ? error.message : 'Unknown error'}`; errors.push(errorMessage); return { cleanedUp, errors }; } } /** * Get reference configuration for debugging */ getReferenceConfig(): ContentReferenceConfig { return { ...this.referenceConfig }; } private async enforceReferenceStorageLimits(): Promise<void> { if (this.contentStore.size >= this.referenceConfig.maxReferences) { await this.performCleanup(); } if (this.referenceStats.totalStorageBytes >= this.referenceConfig.maxTotalStorageBytes) { await this.performCleanup(); } } private calculateExpirationTime(source: ContentSource): Date | undefined { const policy = this.getCleanupPolicy(source); return new Date(Date.now() + policy.maxAgeMs); } private getCleanupPolicy(source: ContentSource) { switch (source) { case 'mcp_tool': return this.referenceConfig.cleanupPolicies.recent; case 'user_upload': return this.referenceConfig.cleanupPolicies.userContent; case 'agent_generated': return this.referenceConfig.cleanupPolicies.agentGenerated; default: return this.referenceConfig.cleanupPolicies.default; } } private detectContentType(content: Buffer, mimeType?: string): ContentType { if (mimeType) { if (mimeType === 'text/html') return 'html'; if (mimeType === 'text/markdown') return 'markdown'; if (mimeType === 'application/json') return 'json'; if (mimeType.startsWith('text/')) return 'text'; return 'binary'; } const contentStr = content.toString('utf8', 0, Math.min(content.length, 1000)); if (contentStr.startsWith('{') || contentStr.startsWith('[')) return 'json'; if (contentStr.includes('<html>') || contentStr.includes('<!DOCTYPE')) return 'html'; if (contentStr.includes('#') && contentStr.includes('\n')) return 'markdown'; return 'text'; } private createContentPreview(content: Buffer, contentType: ContentType): string { const maxLength = 200; let preview = content.toString('utf8', 0, Math.min(content.length, maxLength * 2)); if (contentType === 'html') { preview = preview .replace(/<[^>]*>/g, '') .replace(/\s+/g, ' ') .trim(); } else if (contentType === 'json') { try { const parsed = JSON.parse(preview); preview = JSON.stringify(parsed, null, 0); } catch { } } preview = preview.trim(); if (preview.length > maxLength) { preview = preview.substring(0, maxLength) + '...'; } return preview || '[Binary content]'; } private updateStatsAfterStore(sizeBytes: number): void { this.referenceStats.activeReferences++; this.referenceStats.totalStorageBytes += sizeBytes; this.updateReferenceStorageStats(); } private updateReferenceStorageStats(): void { if (this.referenceStats.activeReferences > 0) { this.referenceStats.averageContentSize = this.referenceStats.totalStorageBytes / this.referenceStats.activeReferences; } this.referenceStats.storageUtilization = (this.referenceStats.totalStorageBytes / this.referenceConfig.maxTotalStorageBytes) * 100; let mostAccessedId: ReferenceId | undefined; let maxAccess = 0; for (const [referenceId, storedContent] of this.contentStore.entries()) { if (storedContent.metadata.accessCount > maxAccess) { maxAccess = storedContent.metadata.accessCount; mostAccessedId = referenceId; } } if (mostAccessedId !== undefined) { this.referenceStats.mostAccessedReferenceId = mostAccessedId; } else { delete this.referenceStats.mostAccessedReferenceId; } } private recordPerformanceMetric(type: 'creation' | 'resolution' | 'cleanup', timeMs: number): void { const metrics = this.referenceStats.performanceMetrics; const maxRecords = 100; switch (type) { case 'creation': metrics.creationTimes.push(timeMs); if (metrics.creationTimes.length > maxRecords) { metrics.creationTimes.shift(); } break; case 'resolution': metrics.resolutionTimes.push(timeMs); if (metrics.resolutionTimes.length > maxRecords) { metrics.resolutionTimes.shift(); } break; case 'cleanup': metrics.cleanupTimes.push(timeMs); if (metrics.cleanupTimes.length > maxRecords) { metrics.cleanupTimes.shift(); } break; } } private calculateAverage(times: number[]): number { if (times.length === 0) return 0; return times.reduce((sum, time) => sum + time, 0) / times.length; } private startReferenceCleanupTimer(): void { this.cleanupTimer = setInterval(async () => { try { await this.performCleanup(); } catch (error) { } }, this.referenceConfig.cleanupIntervalMs); } /** * Clean up resources (enhanced to include reference cleanup) */ async dispose(): Promise<void> { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); delete this.cleanupTimer; } this.contentStore.clear(); this.clear(); } }