| … | … | @@ -4,6 +4,10 @@ Matrix client for the Matrix Command-Line Agent. |
| 4 | 4 | This module provides functionality to connect to Matrix homeservers, |
| 5 | 5 | join rooms, send messages, and listen for incoming messages. |
| 6 | 6 | It integrates with Ollama to process messages and generate responses. |
| 7 | |
| 8 | This implementation uses the ollamatools Matrix integration to provide |
| 9 | enhanced features like search, conversation history tracking, topic analysis, |
| 10 | and performance metrics. |
| 7 | 11 | """ |
| 8 | 12 | |
| 9 | 13 | import os |
| … | … | @@ -22,21 +26,61 @@ logging.basicConfig( |
| 22 | 26 | ) |
| 23 | 27 | logger = logging.getLogger(__name__) |
| 24 | 28 | |
| 29 | # Import from ollamatools Matrix integration |
| 25 | 30 | try: |
| 26 | | from nio import ( |
| 31 | from ollamatools.plugins.integrations.matrix.client import ( |
| 32 | MatrixClient as OllamaToolsMatrixClient, |
| 33 | MatrixConfig as OllamaToolsMatrixConfig, |
| 27 | 34 | AsyncClient, |
| 28 | 35 | MatrixRoom, |
| 29 | 36 | RoomMessageText, |
| 30 | 37 | LoginResponse, |
| 31 | 38 | JoinError |
| 32 | 39 | ) |
| 40 | |
| 41 | from ollamatools.plugins.integrations.matrix.commands import ( |
| 42 | connect_enhanced_matrix, |
| 43 | handle_history_command, |
| 44 | handle_topics_command, |
| 45 | handle_metrics_command |
| 46 | ) |
| 47 | |
| 48 | from ollamatools.plugins.integrations.matrix.search import ( |
| 49 | handle_search_command, |
| 50 | get_default_opensearch_config |
| 51 | ) |
| 52 | |
| 53 | from ollamatools.plugins.integrations.matrix.history import ( |
| 54 | load_conversation_history, |
| 55 | save_conversation_history, |
| 56 | record_conversation, |
| 57 | analyze_conversation_topics, |
| 58 | get_performance_metrics |
| 59 | ) |
| 60 | |
| 61 | OLLAMATOOLS_AVAILABLE = True |
| 33 | 62 | except ImportError: |
| 34 | 63 | logger.error( |
| 35 | | "Matrix support requires the 'matrix-nio' package. " |
| 36 | | "Please install it with 'pip install matrix-nio' or " |
| 37 | | "'pip install matrix-nio[e2e]' for end-to-end encryption support." |
| 64 | "Matrix integration requires the 'ollamatools' package. " |
| 65 | "Please ensure it is installed correctly." |
| 38 | 66 | ) |
| 39 | | sys.exit(1) |
| 67 | # Fall back to direct imports for basic functionality |
| 68 | try: |
| 69 | from nio import ( |
| 70 | AsyncClient, |
| 71 | MatrixRoom, |
| 72 | RoomMessageText, |
| 73 | LoginResponse, |
| 74 | JoinError |
| 75 | ) |
| 76 | except ImportError: |
| 77 | logger.error( |
| 78 | "Matrix support requires the 'matrix-nio' package. " |
| 79 | "Please install it with 'pip install matrix-nio' or " |
| 80 | "'pip install matrix-nio[e2e]' for end-to-end encryption support." |
| 81 | ) |
| 82 | sys.exit(1) |
| 83 | OLLAMATOOLS_AVAILABLE = False |
| 40 | 84 | |
| 41 | 85 | # Make ollama import optional |
| 42 | 86 | try: |
| … | … | @@ -57,6 +101,10 @@ class MatrixClient: |
| 57 | 101 | This client can connect to a Matrix homeserver, join rooms, send messages, |
| 58 | 102 | and listen for incoming messages. It integrates with Ollama to process |
| 59 | 103 | messages and generate responses. |
| 104 | |
| 105 | This implementation uses the ollamatools Matrix integration to provide |
| 106 | enhanced features like search, conversation history tracking, topic analysis, |
| 107 | and performance metrics. |
| 60 | 108 | """ |
| 61 | 109 | |
| 62 | 110 | # Command patterns |
| … | … | @@ -102,6 +150,12 @@ class MatrixClient: |
| 102 | 150 | available_models: List of available Ollama models |
| 103 | 151 | admin_users: List of Matrix user IDs that can change models for all users |
| 104 | 152 | ssl: Whether to use SSL for the Matrix connection |
| 153 | enable_search: Whether to enable search functionality |
| 154 | enable_conversation_tracking: Whether to enable conversation history tracking |
| 155 | opensearch_config: Configuration for OpenSearchNG |
| 156 | history_file: Path to the conversation history file |
| 157 | max_conversation_history: Maximum number of conversations to keep in history |
| 158 | respond_to_all_messages: Whether to respond to all messages (not just mentions) |
| 105 | 159 | """ |
| 106 | 160 | self.homeserver = homeserver |
| 107 | 161 | self.user_id = user_id |
| … | … | @@ -115,25 +169,26 @@ class MatrixClient: |
| 115 | 169 | # Enhanced features |
| 116 | 170 | self.enable_search = enable_search |
| 117 | 171 | self.enable_conversation_tracking = enable_conversation_tracking |
| 118 | | self.opensearch_config = opensearch_config or self._get_default_opensearch_config() |
| 119 | 172 | self.history_file = history_file or os.path.expanduser("~/.config/matrix-cmd/history.json") |
| 120 | 173 | self.max_conversation_history = max_conversation_history |
| 121 | 174 | self.respond_to_all_messages = respond_to_all_messages |
| 122 | 175 | |
| 176 | # Set OpenSearch config |
| 177 | if OLLAMATOOLS_AVAILABLE and hasattr(get_default_opensearch_config, '__call__'): |
| 178 | self.opensearch_config = opensearch_config or get_default_opensearch_config() |
| 179 | else: |
| 180 | self.opensearch_config = opensearch_config or self._get_default_opensearch_config() |
| 181 | |
| 123 | 182 | # Conversation history |
| 124 | 183 | self.conversation_history = [] |
| 125 | 184 | if self.enable_conversation_tracking: |
| 126 | | self.conversation_history = self._load_conversation_history() |
| 185 | if OLLAMATOOLS_AVAILABLE and hasattr(load_conversation_history, '__call__'): |
| 186 | self.conversation_history = load_conversation_history(self.history_file) |
| 187 | else: |
| 188 | self.conversation_history = self._load_conversation_history() |
| 127 | 189 | |
| 128 | | # Initialize the Matrix client |
| 129 | | self.client = AsyncClient( |
| 130 | | homeserver=self.homeserver, |
| 131 | | device_id=self.device_id, |
| 132 | | ssl=self.ssl |
| 133 | | ) |
| 134 | | |
| 135 | | # Set the user ID |
| 136 | | self.client.user_id = self.user_id |
| 190 | # Initialize the client (will be set in connect()) |
| 191 | self.client = None |
| 137 | 192 | |
| 138 | 193 | # Track when the client connected to avoid processing old messages |
| 139 | 194 | self.connection_time = None |
| … | … | @@ -168,11 +223,63 @@ class MatrixClient: |
| 168 | 223 | Returns: |
| 169 | 224 | bool: True if the connection was successful, False otherwise |
| 170 | 225 | """ |
| 226 | if OLLAMATOOLS_AVAILABLE and hasattr(connect_enhanced_matrix, '__call__'): |
| 227 | try: |
| 228 | # Use the ollamatools enhanced Matrix client |
| 229 | enhanced_client = await connect_enhanced_matrix( |
| 230 | homeserver=self.homeserver, |
| 231 | user_id=self.user_id, |
| 232 | password=self.password, |
| 233 | device_id=self.device_id, |
| 234 | ollama_host=self.ollama_host, |
| 235 | ollama_model=self.ollama_model, |
| 236 | ollama_prompt=self.ollama_prompt, |
| 237 | available_models=self.available_models, |
| 238 | admin_users=list(self.admin_users), |
| 239 | opensearch_config=self.opensearch_config, |
| 240 | ssl=self.ssl, |
| 241 | enable_search=self.enable_search, |
| 242 | enable_conversation_tracking=self.enable_conversation_tracking, |
| 243 | max_context_length=self.max_context_length, |
| 244 | max_conversation_history=self.max_conversation_history, |
| 245 | respond_to_all_messages=self.respond_to_all_messages |
| 246 | ) |
| 247 | |
| 248 | if enhanced_client: |
| 249 | # Store the client |
| 250 | self.client = enhanced_client |
| 251 | |
| 252 | # Set connection time |
| 253 | self.connection_time = asyncio.get_event_loop().time() |
| 254 | |
| 255 | # Copy conversation history if available |
| 256 | if hasattr(enhanced_client, 'conversation_history'): |
| 257 | self.conversation_history = enhanced_client.conversation_history |
| 258 | |
| 259 | logger.info(f"Successfully connected to {self.homeserver} as {self.user_id}") |
| 260 | return True |
| 261 | else: |
| 262 | logger.error(f"Failed to connect to {self.homeserver}") |
| 263 | return False |
| 264 | |
| 265 | except Exception as e: |
| 266 | logger.error(f"Error connecting to Matrix server with ollamatools: {e}") |
| 267 | logger.info("Falling back to standard Matrix connection") |
| 268 | # Fall back to standard connection |
| 269 | |
| 270 | # Standard connection (fallback or if ollamatools is not available) |
| 171 | 271 | retry_count = 0 |
| 172 | 272 | current_retry_delay = initial_retry_delay |
| 173 | 273 | |
| 174 | 274 | while retry_count <= max_retries: |
| 175 | 275 | try: |
| 276 | # Initialize the Matrix client |
| 277 | self.client = AsyncClient( |
| 278 | homeserver=self.homeserver, |
| 279 | device_id=self.device_id, |
| 280 | ssl=self.ssl |
| 281 | ) |
| 282 | |
| 176 | 283 | # Extract username from user_id (e.g., "@user:example.com" -> "user") |
| 177 | 284 | username = self.user_id.split(':')[0].lstrip('@') |
| 178 | 285 | |
| … | … | @@ -269,20 +376,41 @@ class MatrixClient: |
| 269 | 376 | Returns: |
| 270 | 377 | bool: True if joining was successful, False otherwise |
| 271 | 378 | """ |
| 272 | | try: |
| 273 | | response = await self.client.join(room_id) |
| 379 | if not self.client: |
| 380 | logger.error("Not connected to Matrix server") |
| 381 | return False |
| 274 | 382 | |
| 275 | | if isinstance(response, JoinError): |
| 276 | | logger.error(f"Failed to join room {room_id}: {response}") |
| 383 | # Check if we're using ollamatools client |
| 384 | if OLLAMATOOLS_AVAILABLE and hasattr(self.client, 'join_room'): |
| 385 | try: |
| 386 | success = await self.client.join_room(room_id) |
| 387 | |
| 388 | if success: |
| 389 | logger.info(f"Successfully joined room {room_id}") |
| 390 | self.rooms.append(room_id) |
| 391 | return True |
| 392 | else: |
| 393 | logger.error(f"Failed to join room {room_id}") |
| 394 | return False |
| 395 | except Exception as e: |
| 396 | logger.error(f"Error joining room {room_id}: {e}") |
| 397 | return False |
| 398 | else: |
| 399 | # Standard client |
| 400 | try: |
| 401 | response = await self.client.join(room_id) |
| 402 | |
| 403 | if isinstance(response, JoinError): |
| 404 | logger.error(f"Failed to join room {room_id}: {response}") |
| 405 | return False |
| 406 | |
| 407 | logger.info(f"Successfully joined room {room_id}") |
| 408 | self.rooms.append(room_id) |
| 409 | return True |
| 410 | |
| 411 | except Exception as e: |
| 412 | logger.error(f"Error joining room {room_id}: {e}") |
| 277 | 413 | return False |
| 278 | | |
| 279 | | logger.info(f"Successfully joined room {room_id}") |
| 280 | | self.rooms.append(room_id) |
| 281 | | return True |
| 282 | | |
| 283 | | except Exception as e: |
| 284 | | logger.error(f"Error joining room {room_id}: {e}") |
| 285 | | return False |
| 286 | 414 | |
| 287 | 415 | async def send_message(self, room_id: str, message: str) -> bool: |
| 288 | 416 | """ |
| … | … | @@ -295,23 +423,44 @@ class MatrixClient: |
| 295 | 423 | Returns: |
| 296 | 424 | bool: True if sending was successful, False otherwise |
| 297 | 425 | """ |
| 298 | | try: |
| 299 | | logger.info(f"Sending message to room {room_id}: {message}") |
| 300 | | response = await self.client.room_send( |
| 301 | | room_id=room_id, |
| 302 | | message_type="m.room.message", |
| 303 | | content={ |
| 304 | | "msgtype": "m.text", |
| 305 | | "body": message |
| 306 | | } |
| 307 | | ) |
| 308 | | |
| 309 | | logger.info(f"Message sent successfully to room {room_id}, response: {response}") |
| 310 | | return True |
| 311 | | |
| 312 | | except Exception as e: |
| 313 | | logger.error(f"Error sending message to room {room_id}: {e}") |
| 426 | if not self.client: |
| 427 | logger.error("Not connected to Matrix server") |
| 314 | 428 | return False |
| 429 | |
| 430 | # Check if we're using ollamatools client |
| 431 | if OLLAMATOOLS_AVAILABLE and hasattr(self.client, 'send_message'): |
| 432 | try: |
| 433 | logger.info(f"Sending message to room {room_id}: {message}") |
| 434 | success = await self.client.send_message(room_id, message) |
| 435 | |
| 436 | if success: |
| 437 | logger.info(f"Message sent successfully to room {room_id}") |
| 438 | return True |
| 439 | else: |
| 440 | logger.error(f"Failed to send message to room {room_id}") |
| 441 | return False |
| 442 | except Exception as e: |
| 443 | logger.error(f"Error sending message to room {room_id}: {e}") |
| 444 | return False |
| 445 | else: |
| 446 | # Standard client |
| 447 | try: |
| 448 | logger.info(f"Sending message to room {room_id}: {message}") |
| 449 | response = await self.client.room_send( |
| 450 | room_id=room_id, |
| 451 | message_type="m.room.message", |
| 452 | content={ |
| 453 | "msgtype": "m.text", |
| 454 | "body": message |
| 455 | } |
| 456 | ) |
| 457 | |
| 458 | logger.info(f"Message sent successfully to room {room_id}") |
| 459 | return True |
| 460 | |
| 461 | except Exception as e: |
| 462 | logger.error(f"Error sending message to room {room_id}: {e}") |
| 463 | return False |
| 315 | 464 | |
| 316 | 465 | async def process_with_ollama(self, message: str, sender: str = None, room_id: str = None) -> str: |
| 317 | 466 | """ |
| … | … | @@ -325,9 +474,25 @@ class MatrixClient: |
| 325 | 474 | Returns: |
| 326 | 475 | str: The response from Ollama |
| 327 | 476 | """ |
| 477 | if not self.client: |
| 478 | logger.error("Not connected to Matrix server") |
| 479 | return "Error: Not connected to Matrix server" |
| 480 | |
| 328 | 481 | if not OLLAMA_AVAILABLE: |
| 329 | 482 | return "Ollama is not available. Please install the 'ollama' package with 'pip install ollama'." |
| 330 | 483 | |
| 484 | # Check if we're using ollamatools client |
| 485 | if OLLAMATOOLS_AVAILABLE and hasattr(self.client, 'process_with_ollama'): |
| 486 | try: |
| 487 | # Use the ollamatools client to process the message |
| 488 | response = await self.client.process_with_ollama(message, sender, room_id) |
| 489 | return response |
| 490 | except Exception as e: |
| 491 | logger.error(f"Error processing message with ollamatools: {e}") |
| 492 | logger.info("Falling back to standard Ollama processing") |
| 493 | # Fall back to standard processing |
| 494 | |
| 495 | # Standard processing |
| 331 | 496 | try: |
| 332 | 497 | ollama_client = OllamaAsync(host=self.ollama_host) |
| 333 | 498 | |
| … | … | @@ -388,6 +553,32 @@ class MatrixClient: |
| 388 | 553 | if len(self.user_contexts[context_key]) > self.max_context_length: |
| 389 | 554 | self.user_contexts[context_key] = self.user_contexts[context_key][-self.max_context_length:] |
| 390 | 555 | |
| 556 | # Record conversation if tracking is enabled |
| 557 | if self.enable_conversation_tracking and sender and room_id: |
| 558 | import time |
| 559 | start_time = time.time() |
| 560 | response_time = time.time() - start_time |
| 561 | |
| 562 | if OLLAMATOOLS_AVAILABLE and hasattr(record_conversation, '__call__'): |
| 563 | record_conversation( |
| 564 | self.conversation_history, |
| 565 | message, |
| 566 | message_content, |
| 567 | sender, |
| 568 | room_id, |
| 569 | response_time, |
| 570 | self.history_file, |
| 571 | self.max_conversation_history |
| 572 | ) |
| 573 | else: |
| 574 | self._record_conversation( |
| 575 | message, |
| 576 | message_content, |
| 577 | sender, |
| 578 | room_id, |
| 579 | response_time |
| 580 | ) |
| 581 | |
| 391 | 582 | logger.debug(f"Received response from Ollama (model: {model}): {message_content}") |
| 392 | 583 | return message_content |
| 393 | 584 | |
| … | … | @@ -439,7 +630,7 @@ class MatrixClient: |
| 439 | 630 | username = self.user_id.split(':')[0].lstrip('@') |
| 440 | 631 | |
| 441 | 632 | # Check if the message contains the bot's username (case insensitive) |
| 442 | | if username.lower() in message.lower(): |
| 633 | if username.lower() in message.lower() or self.respond_to_all_messages: |
| 443 | 634 | logger.info(f"Username mention detected in message: {message}") |
| 444 | 635 | # Process with Ollama and send response |
| 445 | 636 | response = await self.process_with_ollama(message, sender, room_id) |
| … | … | @@ -486,7 +677,12 @@ class MatrixClient: |
| 486 | 677 | await self.send_message(room_id, "Search functionality is not enabled.") |
| 487 | 678 | return |
| 488 | 679 | query = search_match.group(1) |
| 489 | | await self._handle_search_command(query, room_id) |
| 680 | |
| 681 | # Use ollamatools if available |
| 682 | if OLLAMATOOLS_AVAILABLE and hasattr(handle_search_command, '__call__'): |
| 683 | await handle_search_command(self, query, room_id) |
| 684 | else: |
| 685 | await self._handle_search_command(query, room_id) |
| 490 | 686 | return |
| 491 | 687 | |
| 492 | 688 | # Check if this is a history command |
| … | … | @@ -496,7 +692,12 @@ class MatrixClient: |
| 496 | 692 | await self.send_message(room_id, "Conversation tracking is not enabled.") |
| 497 | 693 | return |
| 498 | 694 | count = int(history_match.group(1)) if history_match.group(1) else 10 |
| 499 | | await self._handle_history_command(count, room_id) |
| 695 | |
| 696 | # Use ollamatools if available |
| 697 | if OLLAMATOOLS_AVAILABLE and hasattr(handle_history_command, '__call__'): |
| 698 | await handle_history_command(self, count, room_id) |
| 699 | else: |
| 700 | await self._handle_history_command(count, room_id) |
| 500 | 701 | return |
| 501 | 702 | |
| 502 | 703 | # Check if this is a topics command |
| … | … | @@ -504,7 +705,12 @@ class MatrixClient: |
| 504 | 705 | if not self.enable_conversation_tracking: |
| 505 | 706 | await self.send_message(room_id, "Conversation tracking is not enabled.") |
| 506 | 707 | return |
| 507 | | await self._handle_topics_command(room_id) |
| 708 | |
| 709 | # Use ollamatools if available |
| 710 | if OLLAMATOOLS_AVAILABLE and hasattr(handle_topics_command, '__call__'): |
| 711 | await handle_topics_command(self, room_id) |
| 712 | else: |
| 713 | await self._handle_topics_command(room_id) |
| 508 | 714 | return |
| 509 | 715 | |
| 510 | 716 | # Check if this is a metrics command |
| … | … | @@ -512,7 +718,12 @@ class MatrixClient: |
| 512 | 718 | if not self.enable_conversation_tracking: |
| 513 | 719 | await self.send_message(room_id, "Conversation tracking is not enabled.") |
| 514 | 720 | return |
| 515 | | await self._handle_metrics_command(room_id) |
| 721 | |
| 722 | # Use ollamatools if available |
| 723 | if OLLAMATOOLS_AVAILABLE and hasattr(handle_metrics_command, '__call__'): |
| 724 | await handle_metrics_command(self, room_id) |
| 725 | else: |
| 726 | await self._handle_metrics_command(room_id) |
| 516 | 727 | return |
| 517 | 728 | |
| 518 | 729 | # Unknown command |
| … | … | @@ -591,532 +802,4 @@ For example: "Hey {username}, what's the weather like today?" |
| 591 | 802 | room_id: The ID of the room to send the list to |
| 592 | 803 | """ |
| 593 | 804 | models_text = f"Available models: {', '.join(self.available_models)}" |
| 594 | | await self.send_message(room_id, models_text) |
| 595 | | |
| 596 | | async def get_available_models(self) -> List[str]: |
| 597 | | """ |
| 598 | | Get a list of available Ollama models. |
| 599 | | |
| 600 | | Returns: |
| 601 | | List[str]: A list of available model names |
| 602 | | """ |
| 603 | | if not OLLAMA_AVAILABLE: |
| 604 | | return [self.ollama_model] |
| 605 | | |
| 606 | | try: |
| 607 | | ollama_client = OllamaAsync(host=self.ollama_host) |
| 608 | | response = await ollama_client.list() |
| 609 | | models = [model["name"] for model in response["models"]] |
| 610 | | return models |
| 611 | | except Exception as e: |
| 612 | | logger.error(f"Error getting available models: {e}") |
| 613 | | return [self.ollama_model] |
| 614 | | |
| 615 | | async def update_available_models(self) -> None: |
| 616 | | """ |
| 617 | | Update the list of available models from the Ollama server. |
| 618 | | """ |
| 619 | | models = await self.get_available_models() |
| 620 | | self.available_models = models |
| 621 | | logger.info(f"Updated available models: {', '.join(models)}") |
| 622 | | |
| 623 | | def add_admin_user(self, user_id: str) -> None: |
| 624 | | """ |
| 625 | | Add a user to the list of admin users. |
| 626 | | |
| 627 | | Args: |
| 628 | | user_id: The Matrix user ID to add as an admin |
| 629 | | """ |
| 630 | | self.admin_users.add(user_id) |
| 631 | | logger.info(f"Added admin user: {user_id}") |
| 632 | | |
| 633 | | def remove_admin_user(self, user_id: str) -> None: |
| 634 | | """ |
| 635 | | Remove a user from the list of admin users. |
| 636 | | |
| 637 | | Args: |
| 638 | | user_id: The Matrix user ID to remove as an admin |
| 639 | | """ |
| 640 | | if user_id in self.admin_users: |
| 641 | | self.admin_users.remove(user_id) |
| 642 | | logger.info(f"Removed admin user: {user_id}") |
| 643 | | |
| 644 | | def set_message_callback(self, callback: Callable) -> None: |
| 645 | | """ |
| 646 | | Set a custom callback for handling incoming messages. |
| 647 | | |
| 648 | | The callback should be an async function that takes three arguments: |
| 649 | | - room: The MatrixRoom object |
| 650 | | - event: The RoomMessageText event |
| 651 | | - client: This MatrixClient instance |
| 652 | | |
| 653 | | Args: |
| 654 | | callback: The callback function |
| 655 | | """ |
| 656 | | self.message_callback = callback |
| 657 | | |
| 658 | | async def listen(self) -> None: |
| 659 | | """ |
| 660 | | Start listening for messages in joined rooms. |
| 661 | | |
| 662 | | This method will block until the client is stopped. |
| 663 | | """ |
| 664 | | # Set up the message callback |
| 665 | | self.client.add_event_callback(self._message_callback, RoomMessageText) |
| 666 | | |
| 667 | | # Start syncing with the server |
| 668 | | await self.client.sync_forever(timeout=30000, full_state=True) |
| 669 | | |
| 670 | | async def disconnect(self) -> None: |
| 671 | | """ |
| 672 | | Disconnect from the Matrix homeserver. |
| 673 | | """ |
| 674 | | await self.client.logout() |
| 675 | | await self.client.close() |
| 676 | | logger.info(f"Disconnected from {self.homeserver}") |
| 677 | | |
| 678 | | def _get_default_opensearch_config(self) -> Dict[str, Any]: |
| 679 | | """ |
| 680 | | Get the default OpenSearchNG configuration. |
| 681 | | |
| 682 | | Returns: |
| 683 | | Dict[str, Any]: The default configuration |
| 684 | | """ |
| 685 | | return { |
| 686 | | "BASE_URL": "http://localhost:8081", |
| 687 | | "ENGINES": ["google", "bing", "duckduckgo"], |
| 688 | | "NUM_RESULTS": 5 |
| 689 | | } |
| 690 | | |
| 691 | | def _load_conversation_history(self) -> List[Dict[str, Any]]: |
| 692 | | """ |
| 693 | | Load conversation history from file. |
| 694 | | |
| 695 | | Returns: |
| 696 | | List[Dict[str, Any]]: The conversation history |
| 697 | | """ |
| 698 | | if os.path.exists(self.history_file): |
| 699 | | try: |
| 700 | | import json |
| 701 | | with open(self.history_file, 'r') as f: |
| 702 | | history = json.load(f) |
| 703 | | logger.info(f"Loaded {len(history)} conversations from history") |
| 704 | | return history |
| 705 | | except Exception as e: |
| 706 | | logger.error(f"Failed to load conversation history: {str(e)}") |
| 707 | | return [] |
| 708 | | else: |
| 709 | | logger.info("No conversation history file found") |
| 710 | | return [] |
| 711 | | |
| 712 | | def _save_conversation_history(self) -> None: |
| 713 | | """ |
| 714 | | Save conversation history to file. |
| 715 | | """ |
| 716 | | if not self.enable_conversation_tracking: |
| 717 | | return |
| 718 | | |
| 719 | | try: |
| 720 | | import json |
| 721 | | # Create directory if it doesn't exist |
| 722 | | os.makedirs(os.path.dirname(self.history_file), exist_ok=True) |
| 723 | | |
| 724 | | with open(self.history_file, 'w') as f: |
| 725 | | json.dump(self.conversation_history, f, indent=2) |
| 726 | | logger.debug(f"Saved {len(self.conversation_history)} conversations to history") |
| 727 | | except Exception as e: |
| 728 | | logger.error(f"Failed to save conversation history: {str(e)}") |
| 729 | | |
| 730 | | async def _handle_search_command(self, query: str, room_id: str) -> None: |
| 731 | | """ |
| 732 | | Handle the /search command. |
| 733 | | |
| 734 | | Args: |
| 735 | | query: The search query |
| 736 | | room_id: The ID of the room where the command was sent |
| 737 | | """ |
| 738 | | if not self.enable_search: |
| 739 | | await self.send_message(room_id, "Search functionality is not enabled.") |
| 740 | | return |
| 741 | | |
| 742 | | await self.send_message(room_id, f"Searching for: {query}...") |
| 743 | | |
| 744 | | try: |
| 745 | | # Simple implementation that uses Ollama to generate a response |
| 746 | | # In a real implementation, this would use OpenSearchNG |
| 747 | | search_prompt = f"Please provide a brief search result for the query: {query}" |
| 748 | | response = await self.process_with_ollama(search_prompt, None, room_id) |
| 749 | | |
| 750 | | # Format the response |
| 751 | | formatted_response = f"Search Results for '{query}':\n\n{response}" |
| 752 | | |
| 753 | | # Send the formatted response |
| 754 | | await self.send_message(room_id, formatted_response) |
| 755 | | |
| 756 | | except Exception as e: |
| 757 | | logger.error(f"Error executing search: {e}") |
| 758 | | await self.send_message(room_id, f"Error executing search: {str(e)}") |
| 759 | | |
| 760 | | async def _handle_history_command(self, count: int, room_id: str) -> None: |
| 761 | | """ |
| 762 | | Handle the /history command. |
| 763 | | |
| 764 | | Args: |
| 765 | | count: Number of recent conversations to show |
| 766 | | room_id: The ID of the room where the command was sent |
| 767 | | """ |
| 768 | | if not self.enable_conversation_tracking: |
| 769 | | await self.send_message(room_id, "Conversation tracking is not enabled.") |
| 770 | | return |
| 771 | | |
| 772 | | if not self.conversation_history: |
| 773 | | await self.send_message(room_id, "No conversation history available.") |
| 774 | | return |
| 775 | | |
| 776 | | # Get the most recent conversations for this room |
| 777 | | room_history = [conv for conv in self.conversation_history if conv.get("room_id") == room_id] |
| 778 | | |
| 779 | | if not room_history: |
| 780 | | await self.send_message(room_id, "No conversation history available for this room.") |
| 781 | | return |
| 782 | | |
| 783 | | # Get the most recent conversations |
| 784 | | recent = room_history[-count:] if len(room_history) >= count else room_history |
| 785 | | |
| 786 | | # Format the history |
| 787 | | formatted_history = ["Recent Conversations:"] |
| 788 | | |
| 789 | | for i, conv in enumerate(recent, 1): |
| 790 | | timestamp = conv.get("timestamp", "Unknown time") |
| 791 | | user_message = conv.get("user_message", "No message") |
| 792 | | sender = conv.get("sender", "Unknown sender") |
| 793 | | |
| 794 | | # Truncate message if it's too long |
| 795 | | if len(user_message) > 100: |
| 796 | | user_message = user_message[:100] + "..." |
| 797 | | |
| 798 | | formatted_history.append(f"{i}. {timestamp}") |
| 799 | | formatted_history.append(f" From: {sender}") |
| 800 | | formatted_history.append(f" Message: {user_message}") |
| 801 | | formatted_history.append("") |
| 802 | | |
| 803 | | # Send the formatted history |
| 804 | | await self.send_message(room_id, "\n".join(formatted_history)) |
| 805 | | |
| 806 | | async def _handle_topics_command(self, room_id: str) -> None: |
| 807 | | """ |
| 808 | | Handle the /topics command. |
| 809 | | |
| 810 | | Args: |
| 811 | | room_id: The ID of the room where the command was sent |
| 812 | | """ |
| 813 | | if not self.enable_conversation_tracking: |
| 814 | | await self.send_message(room_id, "Conversation tracking is not enabled.") |
| 815 | | return |
| 816 | | |
| 817 | | if not self.conversation_history: |
| 818 | | await self.send_message(room_id, "No conversation history available.") |
| 819 | | return |
| 820 | | |
| 821 | | # Get the conversations for this room |
| 822 | | room_history = [conv for conv in self.conversation_history if conv.get("room_id") == room_id] |
| 823 | | |
| 824 | | if not room_history: |
| 825 | | await self.send_message(room_id, "No conversation history available for this room.") |
| 826 | | return |
| 827 | | |
| 828 | | # Analyze topics |
| 829 | | topics = self._analyze_conversation_topics(room_history) |
| 830 | | |
| 831 | | if not topics: |
| 832 | | await self.send_message(room_id, "No topics found in conversation history.") |
| 833 | | return |
| 834 | | |
| 835 | | # Format the topics |
| 836 | | formatted_topics = ["Conversation Topics:"] |
| 837 | | |
| 838 | | for topic, count in sorted(topics.items(), key=lambda x: x[1], reverse=True): |
| 839 | | formatted_topics.append(f"- {topic}: {count} mentions") |
| 840 | | |
| 841 | | # Send the formatted topics |
| 842 | | await self.send_message(room_id, "\n".join(formatted_topics)) |
| 843 | | |
| 844 | | async def _handle_metrics_command(self, room_id: str) -> None: |
| 845 | | """ |
| 846 | | Handle the /metrics command. |
| 847 | | |
| 848 | | Args: |
| 849 | | room_id: The ID of the room where the command was sent |
| 850 | | """ |
| 851 | | if not self.enable_conversation_tracking: |
| 852 | | await self.send_message(room_id, "Conversation tracking is not enabled.") |
| 853 | | return |
| 854 | | |
| 855 | | if not self.conversation_history: |
| 856 | | await self.send_message(room_id, "No conversation history available.") |
| 857 | | return |
| 858 | | |
| 859 | | # Get the conversations for this room |
| 860 | | room_history = [conv for conv in self.conversation_history if conv.get("room_id") == room_id] |
| 861 | | |
| 862 | | if not room_history: |
| 863 | | await self.send_message(room_id, "No conversation history available for this room.") |
| 864 | | return |
| 865 | | |
| 866 | | # Calculate metrics |
| 867 | | metrics = self._get_performance_metrics(room_history) |
| 868 | | |
| 869 | | # Format the metrics |
| 870 | | formatted_metrics = ["Performance Metrics:"] |
| 871 | | formatted_metrics.append(f"- Total conversations: {metrics['total_conversations']}") |
| 872 | | formatted_metrics.append(f"- Average response time: {metrics['avg_response_time']:.2f}s") |
| 873 | | formatted_metrics.append(f"- Estimated total tokens: {metrics['total_tokens_estimated']}") |
| 874 | | formatted_metrics.append(f"- Average tokens per conversation: {metrics['avg_tokens_per_conversation']:.1f}") |
| 875 | | |
| 876 | | if metrics.get("slowest_response"): |
| 877 | | formatted_metrics.append(f"- Slowest response: {metrics['slowest_response']['time']:.2f}s for '{metrics['slowest_response']['message']}'") |
| 878 | | |
| 879 | | if metrics.get("fastest_response"): |
| 880 | | formatted_metrics.append(f"- Fastest response: {metrics['fastest_response']['time']:.2f}s for '{metrics['fastest_response']['message']}'") |
| 881 | | |
| 882 | | # Send the formatted metrics |
| 883 | | await self.send_message(room_id, "\n".join(formatted_metrics)) |
| 884 | | |
| 885 | | def _analyze_conversation_topics(self, conversations: List[Dict[str, Any]]) -> Dict[str, int]: |
| 886 | | """ |
| 887 | | Analyze common topics in conversation history. |
| 888 | | |
| 889 | | Args: |
| 890 | | conversations: List of conversation records |
| 891 | | |
| 892 | | Returns: |
| 893 | | Dict[str, int]: Dictionary of topics and their frequency |
| 894 | | """ |
| 895 | | if not conversations: |
| 896 | | return {} |
| 897 | | |
| 898 | | # Simple keyword-based topic analysis |
| 899 | | topics = { |
| 900 | | "help": 0, |
| 901 | | "search": 0, |
| 902 | | "file": 0, |
| 903 | | "command": 0, |
| 904 | | "code": 0, |
| 905 | | "error": 0, |
| 906 | | "issue": 0, |
| 907 | | "question": 0, |
| 908 | | "other": 0 |
| 909 | | } |
| 910 | | |
| 911 | | for conv in conversations: |
| 912 | | message = conv.get("user_message", "").lower() |
| 913 | | |
| 914 | | # Check for keywords |
| 915 | | if "help" in message or "how" in message: |
| 916 | | topics["help"] += 1 |
| 917 | | if "search" in message or "find" in message or "look" in message: |
| 918 | | topics["search"] += 1 |
| 919 | | if "file" in message or "read" in message or "write" in message: |
| 920 | | topics["file"] += 1 |
| 921 | | if "command" in message or "run" in message or "execute" in message: |
| 922 | | topics["command"] += 1 |
| 923 | | if "code" in message or "program" in message or "script" in message: |
| 924 | | topics["code"] += 1 |
| 925 | | if "error" in message or "bug" in message or "fix" in message: |
| 926 | | topics["error"] += 1 |
| 927 | | if "issue" in message or "problem" in message: |
| 928 | | topics["issue"] += 1 |
| 929 | | if "?" in message: |
| 930 | | topics["question"] += 1 |
| 931 | | |
| 932 | | # If no keywords matched, count as other |
| 933 | | if not any(count > 0 for count in topics.values()): |
| 934 | | topics["other"] += 1 |
| 935 | | |
| 936 | | # Remove topics with zero count |
| 937 | | return {k: v for k, v in topics.items() if v > 0} |
| 938 | | |
| 939 | | def _get_performance_metrics(self, conversations: List[Dict[str, Any]]) -> Dict[str, Any]: |
| 940 | | """ |
| 941 | | Get performance metrics from conversation history. |
| 942 | | |
| 943 | | Args: |
| 944 | | conversations: List of conversation records |
| 945 | | |
| 946 | | Returns: |
| 947 | | Dict[str, Any]: Performance metrics |
| 948 | | """ |
| 949 | | if not conversations: |
| 950 | | return {"error": "No conversation history available"} |
| 951 | | |
| 952 | | # Calculate metrics |
| 953 | | total_conversations = len(conversations) |
| 954 | | total_response_time = sum(conv.get("response_time", 0) for conv in conversations) |
| 955 | | avg_response_time = total_response_time / total_conversations if total_conversations > 0 else 0 |
| 956 | | |
| 957 | | # Calculate token usage |
| 958 | | total_tokens = sum(conv.get("tokens_estimated", 0) for conv in conversations) |
| 959 | | avg_tokens_per_conversation = total_tokens / total_conversations if total_conversations > 0 else 0 |
| 960 | | |
| 961 | | # Find slowest and fastest responses |
| 962 | | slowest = max(conversations, key=lambda x: x.get("response_time", 0)) if conversations else None |
| 963 | | fastest = min(conversations, key=lambda x: x.get("response_time", 0)) if conversations else None |
| 964 | | |
| 965 | | return { |
| 966 | | "total_conversations": total_conversations, |
| 967 | | "total_response_time": total_response_time, |
| 968 | | "avg_response_time": avg_response_time, |
| 969 | | "total_tokens_estimated": total_tokens, |
| 970 | | "avg_tokens_per_conversation": avg_tokens_per_conversation, |
| 971 | | "slowest_response": { |
| 972 | | "time": slowest.get("response_time", 0) if slowest else 0, |
| 973 | | "message": slowest.get("user_message", "")[:50] + "..." if slowest and len(slowest.get("user_message", "")) > 50 else slowest.get("user_message", "") if slowest else "" |
| 974 | | }, |
| 975 | | "fastest_response": { |
| 976 | | "time": fastest.get("response_time", 0) if fastest else 0, |
| 977 | | "message": fastest.get("user_message", "")[:50] + "..." if fastest and len(fastest.get("user_message", "")) > 50 else fastest.get("user_message", "") if fastest else "" |
| 978 | | } |
| 979 | | } |
| 980 | | |
| 981 | | def _record_conversation( |
| 982 | | self, |
| 983 | | user_message: str, |
| 984 | | assistant_response: str, |
| 985 | | sender: str, |
| 986 | | room_id: str, |
| 987 | | response_time: float |
| 988 | | ) -> None: |
| 989 | | """ |
| 990 | | Record a conversation in the history. |
| 991 | | |
| 992 | | Args: |
| 993 | | user_message: The user's message |
| 994 | | assistant_response: The assistant's response |
| 995 | | sender: The Matrix user ID of the sender |
| 996 | | room_id: The ID of the room where the conversation took place |
| 997 | | response_time: The time taken to generate the response |
| 998 | | """ |
| 999 | | if not self.enable_conversation_tracking: |
| 1000 | | return |
| 1001 | | |
| 1002 | | import time |
| 1003 | | |
| 1004 | | # Create a conversation record |
| 1005 | | conversation = { |
| 1006 | | "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), |
| 1007 | | "user_message": user_message, |
| 1008 | | "assistant_response": assistant_response, |
| 1009 | | "sender": sender, |
| 1010 | | "room_id": room_id, |
| 1011 | | "response_time": response_time, |
| 1012 | | "tokens_estimated": len(user_message.split()) + len(assistant_response.split()) |
| 1013 | | } |
| 1014 | | |
| 1015 | | # Add to conversation history |
| 1016 | | self.conversation_history.append(conversation) |
| 1017 | | |
| 1018 | | # Trim conversation history if it gets too long |
| 1019 | | if len(self.conversation_history) > self.max_conversation_history: |
| 1020 | | self.conversation_history = self.conversation_history[-self.max_conversation_history:] |
| 1021 | | |
| 1022 | | # Save conversation history |
| 1023 | | self._save_conversation_history() |
| 1024 | | |
| 1025 | | |
| 1026 | | class MatrixConfig: |
| 1027 | | """ |
| 1028 | | Configuration handler for Matrix integration. |
| 1029 | | """ |
| 1030 | | |
| 1031 | | @staticmethod |
| 1032 | | def load_config(config_path: str) -> Dict: |
| 1033 | | """ |
| 1034 | | Load configuration from a YAML file. |
| 1035 | | |
| 1036 | | Args: |
| 1037 | | config_path: Path to the configuration file |
| 1038 | | |
| 1039 | | Returns: |
| 1040 | | Dict: The configuration as a dictionary |
| 1041 | | """ |
| 1042 | | try: |
| 1043 | | with open(config_path, 'r') as f: |
| 1044 | | config = yaml.safe_load(f) |
| 1045 | | |
| 1046 | | logger.info(f"Loaded configuration from {config_path}") |
| 1047 | | return config |
| 1048 | | |
| 1049 | | except Exception as e: |
| 1050 | | logger.error(f"Error loading configuration from {config_path}: {e}") |
| 1051 | | raise |
| 1052 | | |
| 1053 | | @staticmethod |
| 1054 | | def save_config(config: Dict, config_path: str) -> None: |
| 1055 | | """ |
| 1056 | | Save configuration to a YAML file. |
| 1057 | | |
| 1058 | | Args: |
| 1059 | | config: The configuration as a dictionary |
| 1060 | | config_path: Path to save the configuration to |
| 1061 | | """ |
| 1062 | | try: |
| 1063 | | # Create directory if it doesn't exist |
| 1064 | | os.makedirs(os.path.dirname(config_path), exist_ok=True) |
| 1065 | | |
| 1066 | | with open(config_path, 'w') as f: |
| 1067 | | yaml.dump(config, f, default_flow_style=False) |
| 1068 | | |
| 1069 | | logger.info(f"Saved configuration to {config_path}") |
| 1070 | | |
| 1071 | | except Exception as e: |
| 1072 | | logger.error(f"Error saving configuration to {config_path}: {e}") |
| 1073 | | raise |
| 1074 | | |
| 1075 | | @staticmethod |
| 1076 | | def create_default_config() -> Dict: |
| 1077 | | """ |
| 1078 | | Create a default configuration. |
| 1079 | | |
| 1080 | | Returns: |
| 1081 | | Dict: The default configuration |
| 1082 | | """ |
| 1083 | | return { |
| 1084 | | "MATRIX": { |
| 1085 | | "HOMESERVER": "https://matrix.example.org", |
| 1086 | | "USER": "@user:example.org", |
| 1087 | | "PASSWORD": "your-password", |
| 1088 | | "DEVICE_ID": "matrix-cmd-agent", |
| 1089 | | "ROOMS": [ |
| 1090 | | "#room:example.org" |
| 1091 | | ], |
| 1092 | | "ADMIN_USERS": [ |
| 1093 | | "@admin:example.org" |
| 1094 | | ] |
| 1095 | | }, |
| 1096 | | "OLLAMA": { |
| 1097 | | "HOST": "localhost:11434", |
| 1098 | | "MODEL": "llama3:latest", |
| 1099 | | "PROMPT": "You are a helpful assistant.", |
| 1100 | | "AVAILABLE_MODELS": [ |
| 1101 | | "llama3:latest", |
| 1102 | | "mistral:latest", |
| 1103 | | "gemma:latest" |
| 1104 | | ] |
| 1105 | | }, |
| 1106 | | "ENHANCED": { |
| 1107 | | "ENABLE_SEARCH": False, |
| 1108 | | "ENABLE_CONVERSATION_TRACKING": False, |
| 1109 | | "HISTORY_FILE": "~/.config/matrix-cmd/history.json", |
| 1110 | | "MAX_CONVERSATION_HISTORY": 100, |
| 1111 | | "RESPOND_TO_ALL_MESSAGES": False, |
| 1112 | | "OPENSEARCH": { |
| 1113 | | "BASE_URL": "http://localhost:8081", |
| 1114 | | "ENGINES": [ |
| 1115 | | "google", |
| 1116 | | "bing", |
| 1117 | | "duckduckgo" |
| 1118 | | ], |
| 1119 | | "NUM_RESULTS": 5 |
| 1120 | | } |
| 1121 | | } |
| 1122 | | } |
| 805 | await self.send_message(room_id, |