| … | … | @@ -0,0 +1,127 @@ |
| 1 | # Keet Messaging Integration | |
| 2 | ||
| 3 | This module provides integration with Keet, a secure peer-to-peer messaging platform. | |
| 4 | ||
| 5 | ## Overview | |
| 6 | ||
| 7 | The Keet integration allows you to: | |
| 8 | ||
| 9 | 1. Get or create an identity | |
| 10 | 2. Get recent rooms or join a new room | |
| 11 | 3. Send and receive messages | |
| 12 | ||
| 13 | ## Components | |
| 14 | ||
| 15 | - `KeetClient`: A Python client for interacting with the Keet messaging platform | |
| 16 | - `KeetTools`: A plugin that provides tools for interacting with Keet | |
| 17 | - `keet_cli.py`: A command-line interface for Keet messaging | |
| 18 | ||
| 19 | ## Prerequisites | |
| 20 | ||
| 21 | - Node.js (for running the Keet backend) | |
| 22 | - Python 3.6+ (for running the Python client) | |
| 23 | - Keet dependencies installed (see below) | |
| 24 | ||
| 25 | ## Installing Dependencies | |
| 26 | ||
| 27 | The Keet integration requires the following Keet dependencies: | |
| 28 | ||
| 29 | ```bash | |
| 30 | cd tests | |
| 31 | npm install | |
| 32 | ``` | |
| 33 | ||
| 34 | This will install the dependencies specified in `package.json`: | |
| 35 | ||
| 36 | - `@holepunchto/keet-backend`: The Keet backend module | |
| 37 | - `@holepunchto/keet-core-api`: The Keet Core API module | |
| 38 | ||
| 39 | ## Using the Command-Line Interface | |
| 40 | ||
| 41 | The `keet_cli.py` script provides a command-line interface for interacting with Keet: | |
| 42 | ||
| 43 | ```bash | |
| 44 | # Get the current identity | |
| 45 | python -m ollamatools.plugins.integrations.keet.keet_cli get-identity | |
| 46 | ||
| 47 | # Create a new identity | |
| 48 | python -m ollamatools.plugins.integrations.keet.keet_cli create-identity --name "My Name" | |
| 49 | ||
| 50 | # Get recent rooms | |
| 51 | python -m ollamatools.plugins.integrations.keet.keet_cli get-recent-rooms | |
| 52 | ||
| 53 | # Join a room | |
| 54 | python -m ollamatools.plugins.integrations.keet.keet_cli join-room --room-key "pear://keet/yrbsj9ooyqyipzghwfa6n5bouit8f4tuwtfxry41u6hdghk7ao3iuuap31z6d3n4qrqb7eerqp6f9zga5hb6ug9demezyftfarhnu3q57ttxngde" | |
| 55 | ||
| 56 | # Get messages from a room | |
| 57 | python -m ollamatools.plugins.integrations.keet.keet_cli get-messages --room-key "pear://keet/yrbsj9ooyqyipzghwfa6n5bouit8f4tuwtfxry41u6hdghk7ao3iuuap31z6d3n4qrqb7eerqp6f9zga5hb6ug9demezyftfarhnu3q57ttxngde" | |
| 58 | ||
| 59 | # Send a message to a room | |
| 60 | python -m ollamatools.plugins.integrations.keet.keet_cli send-message --room-key "pear://keet/yrbsj9ooyqyipzghwfa6n5bouit8f4tuwtfxry41u6hdghk7ao3iuuap31z6d3n4qrqb7eerqp6f9zga5hb6ug9demezyftfarhnu3q57ttxngde" --message "Hello, world!" | |
| 61 | ``` | |
| 62 | ||
| 63 | ## Using the Python API | |
| 64 | ||
| 65 | You can also use the Python API directly: | |
| 66 | ||
| 67 | ```python | |
| 68 | import asyncio | |
| 69 | from ollamatools.plugins.integrations.keet import KeetClient | |
| 70 | ||
| 71 | async def main(): | |
| 72 | # Initialize the client | |
| 73 | client = KeetClient() | |
| 74 | ||
| 75 | # Get the current identity | |
| 76 | identity = await client.get_identity() | |
| 77 | print(f"Identity: {identity}") | |
| 78 | ||
| 79 | # Get recent rooms | |
| 80 | rooms = await client.get_recent_rooms() | |
| 81 | print(f"Recent rooms: {rooms}") | |
| 82 | ||
| 83 | # Join a room | |
| 84 | room_key = "pear://keet/yrbsj9ooyqyipzghwfa6n5bouit8f4tuwtfxry41u6hdghk7ao3iuuap31z6d3n4qrqb7eerqp6f9zga5hb6ug9demezyftfarhnu3q57ttxngde" | |
| 85 | success = await client.join_room(room_key) | |
| 86 | if success: | |
| 87 | print(f"Successfully joined room: {room_key}") | |
| 88 | ||
| 89 | # Get messages from a room | |
| 90 | messages = await client.get_messages(room_key) | |
| 91 | print(f"Messages: {messages}") | |
| 92 | ||
| 93 | # Send a message to a room | |
| 94 | success = await client.send_message(room_key, "Hello, world!") | |
| 95 | if success: | |
| 96 | print(f"Successfully sent message to room: {room_key}") | |
| 97 | ||
| 98 | if __name__ == "__main__": | |
| 99 | asyncio.run(main()) | |
| 100 | ``` | |
| 101 | ||
| 102 | ## Using the Plugin | |
| 103 | ||
| 104 | The `KeetTools` plugin provides tools for interacting with Keet: | |
| 105 | ||
| 106 | ```python | |
| 107 | from ollamatools.plugins.integrations.keet import KeetTools | |
| 108 | ||
| 109 | # Initialize the plugin | |
| 110 | keet_tools = KeetTools() | |
| 111 | ||
| 112 | # Get the tools | |
| 113 | tools = keet_tools.get_tools() | |
| 114 | ||
| 115 | # Use the tools | |
| 116 | identity = await tools["get_identity"]() | |
| 117 | rooms = await tools["get_recent_rooms"]() | |
| 118 | success = await tools["join_room"]("pear://keet/yrbsj9ooyqyipzghwfa6n5bouit8f4tuwtfxry41u6hdghk7ao3iuuap31z6d3n4qrqb7eerqp6f9zga5hb6ug9demezyftfarhnu3q57ttxngde") | |
| 119 | messages = await tools["get_messages"]("pear://keet/yrbsj9ooyqyipzghwfa6n5bouit8f4tuwtfxry41u6hdghk7ao3iuuap31z6d3n4qrqb7eerqp6f9zga5hb6ug9demezyftfarhnu3q57ttxngde") | |
| 120 | success = await tools["send_message"]("pear://keet/yrbsj9ooyqyipzghwfa6n5bouit8f4tuwtfxry41u6hdghk7ao3iuuap31z6d3n4qrqb7eerqp6f9zga5hb6ug9demezyftfarhnu3q57ttxngde", "Hello, world!") | |
| 121 | ``` | |
| 122 | ||
| 123 | ## Mock Implementation | |
| 124 | ||
| 125 | For testing purposes, the Keet integration includes a mock implementation that simulates the behavior of the real Keet API. This allows you to test your code without requiring the actual Keet dependencies. | |
| 126 | ||
| 127 | The mock implementation is used automatically if the real Keet dependencies are not available. |
| … | … | @@ -30,7 +30,9 @@ You can use either the full pear link invite or just the room key with the Keet |
| 30 | 30 | |
| 31 | 31 | - Node.js (for the JavaScript bridge) |
| 32 | 32 | - Python 3.8+ |
| 33 | - Ollama installed and available in the PATH | |
| 33 | - Ollama installed and running (accessible via API) | |
| 34 | - Node.js packages: | |
| 35 | - node-fetch (for making HTTP requests to the Ollama API) | |
| 34 | 36 | |
| 35 | 37 | ## Installation |
| 36 | 38 | |
| … | … | @@ -48,6 +50,9 @@ You can run the Keet CLI agent from the command line using the `keet_cmd_run` sc |
| 48 | 50 | |
| 49 | 51 | # Or specify a different room key or pear link invite |
| 50 | 52 | ./keet_cmd_run --room-key <room_key_or_pear_link> --name <bot_name> --model <ollama_model> |
| 53 | ||
| 54 | # With additional Ollama configuration | |
| 55 | ./keet_cmd_run --room-key <room_key_or_pear_link> --name <bot_name> --model <ollama_model> --ollama-host <host:port> --prompt "Your system prompt here" | |
| 51 | 56 | ``` |
| 52 | 57 | |
| 53 | 58 | ### Options |
| … | … | @@ -55,9 +60,11 @@ You can run the Keet CLI agent from the command line using the `keet_cmd_run` sc |
| 55 | 60 | - `--storage`: Path to store Keet data. If not provided, a default path will be used. |
| 56 | 61 | - `--room-key`: Room key or invite link to join. If not provided, the default hardcoded room key will be used. |
| 57 | 62 | - `--name`: Display name for the agent. Default is "KeetBot". |
| 58 | - `--model`: Ollama model to use for generating responses. Default is "llama3". | |
| 63 | - `--model`: Ollama model to use for generating responses. Default is "llama3.1:latest". | |
| 64 | - `--ollama-host`: Host address for the Ollama API. Default is "localhost:11434". | |
| 65 | - `--prompt`: System prompt for the Ollama model. If not provided, no system prompt will be used. | |
| 59 | 66 | - `--mock`: Use the mock implementation (for testing). |
| 60 | - `--verbose`: Enable verbose logging. | |
| 67 | - `--verbose`: Enable verbose logging. This provides detailed information about room joining, message sending, and other operations, which is particularly useful for troubleshooting connection issues. | |
| 61 | 68 | |
| 62 | 69 | ### Python API Usage |
| 63 | 70 | |
| … | … | @@ -72,7 +79,9 @@ async def main(): |
| 72 | 79 | agent = KeetCmdAgent( |
| 73 | 80 | room_key="your-room-key", |
| 74 | 81 | name="YourBot", |
| 75 | model="llama3" | |
| 82 | model="llama3.1:latest", | |
| 83 | ollama_host="localhost:11434", | |
| 84 | prompt="You are a helpful assistant." | |
| 76 | 85 | ) |
| 77 | 86 | |
| 78 | 87 | # Start the agent |
| … | … | @@ -111,11 +120,47 @@ The Keet CLI agent consists of two main components: |
| 111 | 120 | |
| 112 | 121 | The JavaScript bridge communicates with the Keet API using the `@holepunchto/keet-backend` and `@holepunchto/keet-core-api` packages. If these packages are not available, a mock implementation is used for testing. |
| 113 | 122 | |
| 114 | The Python wrapper spawns the JavaScript bridge as a subprocess and communicates with it via stdout/stderr. It also spawns Ollama as a subprocess to generate responses to user messages. | |
| 123 | The JavaScript bridge also communicates directly with the Ollama API to generate responses to user messages, maintaining conversation context and supporting system prompts. This approach is consistent with how the Matrix Command-Line Agent interacts with Ollama. | |
| 124 | ||
| 125 | The Python wrapper spawns the JavaScript bridge as a subprocess and communicates with it via stdout/stderr, passing configuration parameters such as the Ollama host, model, and system prompt. | |
| 126 | ||
| 127 | ## Verbose Output | |
| 128 | ||
| 129 | When the `--verbose` flag is enabled, the agent provides detailed logging about its operations, including: | |
| 130 | ||
| 131 | - Room joining process and verification | |
| 132 | - Message sending attempts and confirmations | |
| 133 | - Message retrieval and processing details | |
| 134 | - Connection status and errors with stack traces | |
| 135 | - Polling intervals and timing information | |
| 136 | ||
| 137 | This verbose output is particularly useful for troubleshooting issues with: | |
| 138 | - Room joining failures | |
| 139 | - Message sending problems | |
| 140 | - Connection interruptions | |
| 141 | - API compatibility issues | |
| 142 | ||
| 143 | Example verbose output: | |
| 144 | ||
| 145 | ``` | |
| 146 | [2025-04-21T18:00:00.000Z] [INFO] Joining room with key: pear://keet/yrb6mha3... | |
| 147 | [2025-04-21T18:00:00.000Z] [VERBOSE] Extracted room key from pear link: yrb6mha3... | |
| 148 | [2025-04-21T18:00:00.000Z] [INFO] Using pairRoom method to join the room... | |
| 149 | [2025-04-21T18:00:00.000Z] [VERBOSE] Attempting to pair with room using key: yrb6mha3... | |
| 150 | [2025-04-21T18:00:00.000Z] [VERBOSE] Starting room pairing process... | |
| 151 | [2025-04-21T18:00:00.000Z] [INFO] ✓ Successfully paired with the room | |
| 152 | [2025-04-21T18:00:00.000Z] [VERBOSE] Room verified in recent rooms list | |
| 153 | [2025-04-21T18:00:00.000Z] [INFO] Sending initial greeting message to room yrb6mha3... | |
| 154 | [2025-04-21T18:00:00.000Z] [VERBOSE] Full message: "Hello! I'm KeetBot, a Keet bot powered by Ollama. I'm here to assist you." | |
| 155 | [2025-04-21T18:00:00.000Z] [VERBOSE] Attempting to send initial message... | |
| 156 | [2025-04-21T18:00:00.000Z] [INFO] ✓ Initial greeting message sent successfully | |
| 157 | ``` | |
| 115 | 158 | |
| 116 | 159 | ## Limitations |
| 117 | 160 | |
| 118 | 161 | - The Keet CLI agent is a minimal implementation and may not support all Keet features. |
| 119 | 162 | - The agent does not support file transfers or other advanced Keet features. |
| 120 | - The agent requires Ollama to be installed and available in the PATH. | |
| 163 | - The agent requires Ollama to be running and accessible via its API. | |
| 164 | - The agent maintains conversation context but has a limit on the number of messages it remembers. | |
| 121 | 165 | - The agent may not work with all Ollama models. |
| 166 | - In some network configurations, you may need to use the `--verbose` flag to diagnose connection issues. |
| … | … | @@ -0,0 +1,10 @@ |
| 1 | """ | |
| 2 | Keet messaging integration for ollamatools. | |
| 3 | ||
| 4 | This module provides integration with Keet, a secure peer-to-peer messaging platform. | |
| 5 | """ | |
| 6 | ||
| 7 | from .client import KeetClient | |
| 8 | from .tools import KeetTools | |
| 9 | ||
| 10 | __all__ = ["KeetClient", "KeetTools"] |
| … | … | @@ -0,0 +1,254 @@ |
| 1 | """ | |
| 2 | Keet client for ollamatools. | |
| 3 | ||
| 4 | This module provides a Python interface to the Keet messaging platform. | |
| 5 | """ | |
| 6 | ||
| 7 | import asyncio | |
| 8 | import json | |
| 9 | import logging | |
| 10 | import os | |
| 11 | import tempfile | |
| 12 | from pathlib import Path | |
| 13 | from typing import Dict, List, Optional, Any, Union, Tuple | |
| 14 | ||
| 15 | from .utils import ( | |
| 16 | is_valid_hypercore_key, | |
| 17 | extract_room_key_from_invite, | |
| 18 | normalize_hypercore_key, | |
| 19 | run_node_script, | |
| 20 | get_default_storage_path, | |
| 21 | ) | |
| 22 | ||
| 23 | logger = logging.getLogger(__name__) | |
| 24 | ||
| 25 | class KeetClient: | |
| 26 | """ | |
| 27 | Client for interacting with the Keet messaging platform. | |
| 28 | ||
| 29 | This client uses the Node.js Keet API via subprocess calls. | |
| 30 | """ | |
| 31 | ||
| 32 | def __init__(self, storage_path: Optional[Union[str, Path]] = None): | |
| 33 | """ | |
| 34 | Initialize the Keet client. | |
| 35 | ||
| 36 | Args: | |
| 37 | storage_path: Path to store Keet data. If None, a default path will be used. | |
| 38 | """ | |
| 39 | self.storage_path = Path(storage_path) if storage_path else get_default_storage_path() | |
| 40 | self.storage_path.mkdir(parents=True, exist_ok=True) | |
| 41 | ||
| 42 | # Path to the JavaScript bridge scripts | |
| 43 | self._script_dir = Path(__file__).parent.parent.parent.parent.parent / "tests" | |
| 44 | self._test_script = self._script_dir / "keet_messaging_test.js" | |
| 45 | self._mock_script = self._script_dir / "keet_messaging_mock_test.js" | |
| 46 | ||
| 47 | # Check if the scripts exist | |
| 48 | if not self._test_script.exists(): | |
| 49 | logger.warning(f"Keet test script not found at {self._test_script}") | |
| 50 | ||
| 51 | if not self._mock_script.exists(): | |
| 52 | logger.warning(f"Keet mock script not found at {self._mock_script}") | |
| 53 | ||
| 54 | # Flag to indicate whether to use the mock implementation | |
| 55 | self._use_mock = not self._test_script.exists() | |
| 56 | ||
| 57 | logger.info(f"Initialized Keet client with storage path: {self.storage_path}") | |
| 58 | logger.info(f"Using {'mock' if self._use_mock else 'real'} implementation") | |
| 59 | ||
| 60 | async def get_identity(self) -> Dict[str, Any]: | |
| 61 | """ | |
| 62 | Get the current identity or create a new one if none exists. | |
| 63 | ||
| 64 | Returns: | |
| 65 | Dict[str, Any]: The identity information | |
| 66 | """ | |
| 67 | # Create a temporary file to store the result | |
| 68 | with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as temp_file: | |
| 69 | temp_path = temp_file.name | |
| 70 | ||
| 71 | try: | |
| 72 | # Run the script with the "get-identity" command | |
| 73 | script = self._mock_script if self._use_mock else self._test_script | |
| 74 | success, output = run_node_script(script, ["--command", "get-identity", "--storage", str(self.storage_path), "--output", temp_path]) | |
| 75 | ||
| 76 | if not success: | |
| 77 | logger.error(f"Failed to get identity: {output}") | |
| 78 | return {} | |
| 79 | ||
| 80 | # Read the result from the temporary file | |
| 81 | with open(temp_path, "r") as f: | |
| 82 | result = json.load(f) | |
| 83 | ||
| 84 | return result | |
| 85 | finally: | |
| 86 | # Clean up the temporary file | |
| 87 | if os.path.exists(temp_path): | |
| 88 | os.unlink(temp_path) | |
| 89 | ||
| 90 | async def create_identity(self, name: Optional[str] = None) -> Dict[str, Any]: | |
| 91 | """ | |
| 92 | Create a new identity. | |
| 93 | ||
| 94 | Args: | |
| 95 | name: The display name for the identity | |
| 96 | ||
| 97 | Returns: | |
| 98 | Dict[str, Any]: The created identity information | |
| 99 | """ | |
| 100 | # Create a temporary file to store the result | |
| 101 | with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as temp_file: | |
| 102 | temp_path = temp_file.name | |
| 103 | ||
| 104 | try: | |
| 105 | # Run the script with the "create-identity" command | |
| 106 | script = self._mock_script if self._use_mock else self._test_script | |
| 107 | args = ["--command", "create-identity", "--storage", str(self.storage_path), "--output", temp_path] | |
| 108 | ||
| 109 | if name: | |
| 110 | args.extend(["--name", name]) | |
| 111 | ||
| 112 | success, output = run_node_script(script, args) | |
| 113 | ||
| 114 | if not success: | |
| 115 | logger.error(f"Failed to create identity: {output}") | |
| 116 | return {} | |
| 117 | ||
| 118 | # Read the result from the temporary file | |
| 119 | with open(temp_path, "r") as f: | |
| 120 | result = json.load(f) | |
| 121 | ||
| 122 | return result | |
| 123 | finally: | |
| 124 | # Clean up the temporary file | |
| 125 | if os.path.exists(temp_path): | |
| 126 | os.unlink(temp_path) | |
| 127 | ||
| 128 | async def get_recent_rooms(self) -> List[Dict[str, Any]]: | |
| 129 | """ | |
| 130 | Get a list of recent rooms. | |
| 131 | ||
| 132 | Returns: | |
| 133 | List[Dict[str, Any]]: List of room information | |
| 134 | """ | |
| 135 | # Create a temporary file to store the result | |
| 136 | with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as temp_file: | |
| 137 | temp_path = temp_file.name | |
| 138 | ||
| 139 | try: | |
| 140 | # Run the script with the "get-recent-rooms" command | |
| 141 | script = self._mock_script if self._use_mock else self._test_script | |
| 142 | success, output = run_node_script(script, ["--command", "get-recent-rooms", "--storage", str(self.storage_path), "--output", temp_path]) | |
| 143 | ||
| 144 | if not success: | |
| 145 | logger.error(f"Failed to get recent rooms: {output}") | |
| 146 | return [] | |
| 147 | ||
| 148 | # Read the result from the temporary file | |
| 149 | with open(temp_path, "r") as f: | |
| 150 | result = json.load(f) | |
| 151 | ||
| 152 | return result | |
| 153 | finally: | |
| 154 | # Clean up the temporary file | |
| 155 | if os.path.exists(temp_path): | |
| 156 | os.unlink(temp_path) | |
| 157 | ||
| 158 | async def join_room(self, room_key: str) -> bool: | |
| 159 | """ | |
| 160 | Join a room using a room key. | |
| 161 | ||
| 162 | Args: | |
| 163 | room_key: The room key or invite link | |
| 164 | ||
| 165 | Returns: | |
| 166 | bool: True if successful, False otherwise | |
| 167 | """ | |
| 168 | # Normalize the room key | |
| 169 | normalized_key = normalize_hypercore_key(room_key) | |
| 170 | ||
| 171 | # Validate the room key | |
| 172 | if not is_valid_hypercore_key(normalized_key): | |
| 173 | logger.error(f"Invalid Hypercore key: {normalized_key}") | |
| 174 | return False | |
| 175 | ||
| 176 | # Run the script with the "join-room" command | |
| 177 | script = self._mock_script if self._use_mock else self._test_script | |
| 178 | success, output = run_node_script(script, ["--command", "join-room", "--storage", str(self.storage_path), "--room-key", normalized_key]) | |
| 179 | ||
| 180 | if not success: | |
| 181 | logger.error(f"Failed to join room: {output}") | |
| 182 | return False | |
| 183 | ||
| 184 | return True | |
| 185 | ||
| 186 | async def get_messages(self, room_key: str) -> List[Dict[str, Any]]: | |
| 187 | """ | |
| 188 | Get messages from a room. | |
| 189 | ||
| 190 | Args: | |
| 191 | room_key: The room key | |
| 192 | ||
| 193 | Returns: | |
| 194 | List[Dict[str, Any]]: List of messages | |
| 195 | """ | |
| 196 | # Normalize the room key | |
| 197 | normalized_key = normalize_hypercore_key(room_key) | |
| 198 | ||
| 199 | # Validate the room key | |
| 200 | if not is_valid_hypercore_key(normalized_key): | |
| 201 | logger.error(f"Invalid Hypercore key: {normalized_key}") | |
| 202 | return [] | |
| 203 | ||
| 204 | # Create a temporary file to store the result | |
| 205 | with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as temp_file: | |
| 206 | temp_path = temp_file.name | |
| 207 | ||
| 208 | try: | |
| 209 | # Run the script with the "get-messages" command | |
| 210 | script = self._mock_script if self._use_mock else self._test_script | |
| 211 | success, output = run_node_script(script, ["--command", "get-messages", "--storage", str(self.storage_path), "--room-key", normalized_key, "--output", temp_path]) | |
| 212 | ||
| 213 | if not success: | |
| 214 | logger.error(f"Failed to get messages: {output}") | |
| 215 | return [] | |
| 216 | ||
| 217 | # Read the result from the temporary file | |
| 218 | with open(temp_path, "r") as f: | |
| 219 | result = json.load(f) | |
| 220 | ||
| 221 | return result | |
| 222 | finally: | |
| 223 | # Clean up the temporary file | |
| 224 | if os.path.exists(temp_path): | |
| 225 | os.unlink(temp_path) | |
| 226 | ||
| 227 | async def send_message(self, room_key: str, message: str) -> bool: | |
| 228 | """ | |
| 229 | Send a message to a room. | |
| 230 | ||
| 231 | Args: | |
| 232 | room_key: The room key | |
| 233 | message: The message to send | |
| 234 | ||
| 235 | Returns: | |
| 236 | bool: True if successful, False otherwise | |
| 237 | """ | |
| 238 | # Normalize the room key | |
| 239 | normalized_key = normalize_hypercore_key(room_key) | |
| 240 | ||
| 241 | # Validate the room key | |
| 242 | if not is_valid_hypercore_key(normalized_key): | |
| 243 | logger.error(f"Invalid Hypercore key: {normalized_key}") | |
| 244 | return False | |
| 245 | ||
| 246 | # Run the script with the "send-message" command | |
| 247 | script = self._mock_script if self._use_mock else self._test_script | |
| 248 | success, output = run_node_script(script, ["--command", "send-message", "--storage", str(self.storage_path), "--room-key", normalized_key, "--message", message]) | |
| 249 | ||
| 250 | if not success: | |
| 251 | logger.error(f"Failed to send message: {output}") | |
| 252 | return False | |
| 253 | ||
| 254 | return True |
| … | … | @@ -0,0 +1,119 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """ | |
| 3 | Command-line interface for Keet messaging. | |
| 4 | ||
| 5 | This script provides a command-line interface for interacting with the Keet messaging platform. | |
| 6 | """ | |
| 7 | ||
| 8 | import argparse | |
| 9 | import asyncio | |
| 10 | import json | |
| 11 | import logging | |
| 12 | import os | |
| 13 | import sys | |
| 14 | from pathlib import Path | |
| 15 | from typing import Dict, List, Any, Optional | |
| 16 | ||
| 17 | from .client import KeetClient | |
| 18 | from .utils import extract_room_key_from_invite, is_valid_hypercore_key, normalize_hypercore_key | |
| 19 | ||
| 20 | # Set up logging | |
| 21 | logging.basicConfig(level=logging.INFO) | |
| 22 | logger = logging.getLogger(__name__) | |
| 23 | ||
| 24 | async def main(): | |
| 25 | """Main entry point for the Keet CLI.""" | |
| 26 | parser = argparse.ArgumentParser(description="Command-line interface for Keet messaging") | |
| 27 | parser.add_argument("--storage", type=str, help="Path to store Keet data") | |
| 28 | ||
| 29 | subparsers = parser.add_subparsers(dest="command", help="Command to execute") | |
| 30 | ||
| 31 | # Get identity command | |
| 32 | get_identity_parser = subparsers.add_parser("get-identity", help="Get the current identity") | |
| 33 | ||
| 34 | # Create identity command | |
| 35 | create_identity_parser = subparsers.add_parser("create-identity", help="Create a new identity") | |
| 36 | create_identity_parser.add_argument("--name", type=str, help="Display name for the identity") | |
| 37 | ||
| 38 | # Get recent rooms command | |
| 39 | get_recent_rooms_parser = subparsers.add_parser("get-recent-rooms", help="Get a list of recent rooms") | |
| 40 | ||
| 41 | # Join room command | |
| 42 | join_room_parser = subparsers.add_parser("join-room", help="Join a room") | |
| 43 | join_room_parser.add_argument("--room-key", type=str, required=True, help="Room key or invite link") | |
| 44 | ||
| 45 | # Get messages command | |
| 46 | get_messages_parser = subparsers.add_parser("get-messages", help="Get messages from a room") | |
| 47 | get_messages_parser.add_argument("--room-key", type=str, required=True, help="Room key or invite link") | |
| 48 | ||
| 49 | # Send message command | |
| 50 | send_message_parser = subparsers.add_parser("send-message", help="Send a message to a room") | |
| 51 | send_message_parser.add_argument("--room-key", type=str, required=True, help="Room key or invite link") | |
| 52 | send_message_parser.add_argument("--message", type=str, required=True, help="Message to send") | |
| 53 | ||
| 54 | # Output file option for commands that return data | |
| 55 | for p in [get_identity_parser, create_identity_parser, get_recent_rooms_parser, get_messages_parser]: | |
| 56 | p.add_argument("--output", type=str, help="Output file to write the result to (JSON format)") | |
| 57 | ||
| 58 | args = parser.parse_args() | |
| 59 | ||
| 60 | if not args.command: | |
| 61 | parser.print_help() | |
| 62 | return 1 | |
| 63 | ||
| 64 | # Initialize the client | |
| 65 | client = KeetClient(args.storage) | |
| 66 | ||
| 67 | # Execute the command | |
| 68 | if args.command == "get-identity": | |
| 69 | result = await client.get_identity() | |
| 70 | if args.output: | |
| 71 | with open(args.output, "w") as f: | |
| 72 | json.dump(result, f) | |
| 73 | else: | |
| 74 | print(json.dumps(result, indent=2)) | |
| 75 | ||
| 76 | elif args.command == "create-identity": | |
| 77 | result = await client.create_identity(args.name) | |
| 78 | if args.output: | |
| 79 | with open(args.output, "w") as f: | |
| 80 | json.dump(result, f) | |
| 81 | else: | |
| 82 | print(json.dumps(result, indent=2)) | |
| 83 | ||
| 84 | elif args.command == "get-recent-rooms": | |
| 85 | result = await client.get_recent_rooms() | |
| 86 | if args.output: | |
| 87 | with open(args.output, "w") as f: | |
| 88 | json.dump(result, f) | |
| 89 | else: | |
| 90 | print(json.dumps(result, indent=2)) | |
| 91 | ||
| 92 | elif args.command == "join-room": | |
| 93 | success = await client.join_room(args.room_key) | |
| 94 | if success: | |
| 95 | logger.info(f"Successfully joined room: {args.room_key}") | |
| 96 | else: | |
| 97 | logger.error(f"Failed to join room: {args.room_key}") | |
| 98 | return 1 | |
| 99 | ||
| 100 | elif args.command == "get-messages": | |
| 101 | result = await client.get_messages(args.room_key) | |
| 102 | if args.output: | |
| 103 | with open(args.output, "w") as f: | |
| 104 | json.dump(result, f) | |
| 105 | else: | |
| 106 | print(json.dumps(result, indent=2)) | |
| 107 | ||
| 108 | elif args.command == "send-message": | |
| 109 | success = await client.send_message(args.room_key, args.message) | |
| 110 | if success: | |
| 111 | logger.info(f"Successfully sent message to room: {args.room_key}") | |
| 112 | else: | |
| 113 | logger.error(f"Failed to send message to room: {args.room_key}") | |
| 114 | return 1 | |
| 115 | ||
| 116 | return 0 | |
| 117 | ||
| 118 | if __name__ == "__main__": | |
| 119 | sys.exit(asyncio.run(main())) |
| … | … | @@ -31,7 +31,9 @@ class KeetCmdAgent: |
| 31 | 31 | storage_path: Optional[Union[str, Path]] = None, |
| 32 | 32 | room_key: Optional[str] = None, |
| 33 | 33 | name: str = "KeetBot", |
| 34 | model: str = "llama3", | |
| 34 | model: str = "llama3.1:latest", | |
| 35 | ollama_host: str = "localhost:11434", | |
| 36 | prompt: Optional[str] = None, | |
| 35 | 37 | mock: bool = False, |
| 36 | 38 | verbose: bool = False |
| 37 | 39 | ): |
| … | … | @@ -43,6 +45,8 @@ class KeetCmdAgent: |
| 43 | 45 | room_key: The room key or invite link to join. If None, the most recent room will be used. |
| 44 | 46 | name: The display name for the agent. |
| 45 | 47 | model: The Ollama model to use for generating responses. |
| 48 | ollama_host: The host address for the Ollama API. | |
| 49 | prompt: An optional system prompt for the Ollama model. | |
| 46 | 50 | mock: Whether to use the mock implementation. |
| 47 | 51 | verbose: Whether to enable verbose logging. |
| 48 | 52 | """ |
| … | … | @@ -50,6 +54,8 @@ class KeetCmdAgent: |
| 50 | 54 | self.room_key = room_key |
| 51 | 55 | self.name = name |
| 52 | 56 | self.model = model |
| 57 | self.ollama_host = ollama_host | |
| 58 | self.prompt = prompt | |
| 53 | 59 | self.mock = mock |
| 54 | 60 | self.verbose = verbose |
| 55 | 61 | |
| … | … | @@ -88,6 +94,10 @@ class KeetCmdAgent: |
| 88 | 94 | |
| 89 | 95 | cmd.extend(["--name", self.name]) |
| 90 | 96 | cmd.extend(["--model", self.model]) |
| 97 | cmd.extend(["--ollama-host", self.ollama_host]) | |
| 98 | ||
| 99 | if self.prompt: | |
| 100 | cmd.extend(["--prompt", self.prompt]) | |
| 91 | 101 | |
| 92 | 102 | if self.mock: |
| 93 | 103 | cmd.append("--mock") |
| … | … | @@ -200,7 +210,9 @@ async def main(): |
| 200 | 210 | parser.add_argument("--storage", type=str, help="Path to store Keet data") |
| 201 | 211 | parser.add_argument("--room-key", type=str, help="Room key or invite link to join") |
| 202 | 212 | parser.add_argument("--name", type=str, default="KeetBot", help="Display name for the agent") |
| 203 | parser.add_argument("--model", type=str, default="llama3", help="Ollama model to use") | |
| 213 | parser.add_argument("--model", type=str, default="llama3.1:latest", help="Ollama model to use") | |
| 214 | parser.add_argument("--ollama-host", type=str, default="localhost:11434", help="Host address for the Ollama API") | |
| 215 | parser.add_argument("--prompt", type=str, help="System prompt for the Ollama model") | |
| 204 | 216 | parser.add_argument("--mock", action="store_true", help="Use the mock implementation") |
| 205 | 217 | parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") |
| 206 | 218 | |
| … | … | @@ -212,6 +224,8 @@ async def main(): |
| 212 | 224 | room_key=args.room_key, |
| 213 | 225 | name=args.name, |
| 214 | 226 | model=args.model, |
| 227 | ollama_host=args.ollama_host, | |
| 228 | prompt=args.prompt, | |
| 215 | 229 | mock=args.mock, |
| 216 | 230 | verbose=args.verbose |
| 217 | 231 | ) |
| … | … | @@ -0,0 +1,147 @@ |
| 1 | """ | |
| 2 | Tools for Keet messaging integration. | |
| 3 | ||
| 4 | This module provides tools for interacting with the Keet messaging platform. | |
| 5 | """ | |
| 6 | ||
| 7 | import asyncio | |
| 8 | import logging | |
| 9 | from typing import Dict, List, Any, Optional, Union | |
| 10 | from pathlib import Path | |
| 11 | ||
| 12 | from ollamatools.plugins.base import ToolPlugin | |
| 13 | from .client import KeetClient | |
| 14 | from .utils import extract_room_key_from_invite, is_valid_hypercore_key, normalize_hypercore_key | |
| 15 | ||
| 16 | logger = logging.getLogger(__name__) | |
| 17 | ||
| 18 | class KeetTools(ToolPlugin): | |
| 19 | """ | |
| 20 | Tools for interacting with the Keet messaging platform. | |
| 21 | """ | |
| 22 | ||
| 23 | def __init__(self, storage_path: Optional[Union[str, Path]] = None): | |
| 24 | """ | |
| 25 | Initialize the Keet tools. | |
| 26 | ||
| 27 | Args: | |
| 28 | storage_path: Path to store Keet data. If None, a default path will be used. | |
| 29 | """ | |
| 30 | super().__init__() | |
| 31 | self.client = KeetClient(storage_path) | |
| 32 | logger.info("Initialized Keet tools") | |
| 33 | ||
| 34 | async def get_identity(self) -> Dict[str, Any]: | |
| 35 | """ | |
| 36 | Get the current identity or create a new one if none exists. | |
| 37 | ||
| 38 | Returns: | |
| 39 | Dict[str, Any]: The identity information | |
| 40 | """ | |
| 41 | return await self.client.get_identity() | |
| 42 | ||
| 43 | async def create_identity(self, name: Optional[str] = None) -> Dict[str, Any]: | |
| 44 | """ | |
| 45 | Create a new identity. | |
| 46 | ||
| 47 | Args: | |
| 48 | name: The display name for the identity | |
| 49 | ||
| 50 | Returns: | |
| 51 | Dict[str, Any]: The created identity information | |
| 52 | """ | |
| 53 | return await self.client.create_identity(name) | |
| 54 | ||
| 55 | async def get_recent_rooms(self) -> List[Dict[str, Any]]: | |
| 56 | """ | |
| 57 | Get a list of recent rooms. | |
| 58 | ||
| 59 | Returns: | |
| 60 | List[Dict[str, Any]]: List of room information | |
| 61 | """ | |
| 62 | return await self.client.get_recent_rooms() | |
| 63 | ||
| 64 | async def join_room(self, room_key: str) -> bool: | |
| 65 | """ | |
| 66 | Join a room using a room key or invite link. | |
| 67 | ||
| 68 | Args: | |
| 69 | room_key: The room key or invite link | |
| 70 | ||
| 71 | Returns: | |
| 72 | bool: True if successful, False otherwise | |
| 73 | """ | |
| 74 | # Extract the room key if it's an invite link | |
| 75 | if room_key.startswith("pear://keet/"): | |
| 76 | room_key = extract_room_key_from_invite(room_key) | |
| 77 | ||
| 78 | return await self.client.join_room(room_key) | |
| 79 | ||
| 80 | async def get_messages(self, room_key: str) -> List[Dict[str, Any]]: | |
| 81 | """ | |
| 82 | Get messages from a room. | |
| 83 | ||
| 84 | Args: | |
| 85 | room_key: The room key or invite link | |
| 86 | ||
| 87 | Returns: | |
| 88 | List[Dict[str, Any]]: List of messages | |
| 89 | """ | |
| 90 | # Extract the room key if it's an invite link | |
| 91 | if room_key.startswith("pear://keet/"): | |
| 92 | room_key = extract_room_key_from_invite(room_key) | |
| 93 | ||
| 94 | return await self.client.get_messages(room_key) | |
| 95 | ||
| 96 | async def send_message(self, room_key: str, message: str) -> bool: | |
| 97 | """ | |
| 98 | Send a message to a room. | |
| 99 | ||
| 100 | Args: | |
| 101 | room_key: The room key or invite link | |
| 102 | message: The message to send | |
| 103 | ||
| 104 | Returns: | |
| 105 | bool: True if successful, False otherwise | |
| 106 | """ | |
| 107 | # Extract the room key if it's an invite link | |
| 108 | if room_key.startswith("pear://keet/"): | |
| 109 | room_key = extract_room_key_from_invite(room_key) | |
| 110 | ||
| 111 | return await self.client.send_message(room_key, message) | |
| 112 | ||
| 113 | # ToolPlugin interface methods | |
| 114 | ||
| 115 | def get_name(self) -> str: | |
| 116 | """ | |
| 117 | Get the name of the plugin. | |
| 118 | ||
| 119 | Returns: | |
| 120 | str: The plugin name | |
| 121 | """ | |
| 122 | return "keet" | |
| 123 | ||
| 124 | def get_description(self) -> str: | |
| 125 | """ | |
| 126 | Get the description of the plugin. | |
| 127 | ||
| 128 | Returns: | |
| 129 | str: The plugin description | |
| 130 | """ | |
| 131 | return "Tools for interacting with the Keet messaging platform" | |
| 132 | ||
| 133 | def get_tools(self) -> Dict[str, Any]: | |
| 134 | """ | |
| 135 | Get the tools provided by this plugin. | |
| 136 | ||
| 137 | Returns: | |
| 138 | Dict[str, Any]: A dictionary of tool names to tool functions | |
| 139 | """ | |
| 140 | return { | |
| 141 | "get_identity": self.get_identity, | |
| 142 | "create_identity": self.create_identity, | |
| 143 | "get_recent_rooms": self.get_recent_rooms, | |
| 144 | "join_room": self.join_room, | |
| 145 | "get_messages": self.get_messages, | |
| 146 | "send_message": self.send_message | |
| 147 | } |
| … | … | @@ -0,0 +1,143 @@ |
| 1 | """ | |
| 2 | Utility functions for Keet messaging integration. | |
| 3 | """ | |
| 4 | ||
| 5 | import re | |
| 6 | import logging | |
| 7 | import subprocess | |
| 8 | import json | |
| 9 | import os | |
| 10 | from pathlib import Path | |
| 11 | from typing import Optional, Dict, Any, List, Union, Tuple | |
| 12 | ||
| 13 | logger = logging.getLogger(__name__) | |
| 14 | ||
| 15 | # Constants | |
| 16 | KEET_INVITE_PREFIX = "pear://keet/" | |
| 17 | Z_BASE32_PATTERN = re.compile(r"^[a-z2-7]+$") | |
| 18 | HYPERCORE_KEY_LENGTH = 52 # Standard length for z-base-32 encoded Hypercore keys | |
| 19 | ||
| 20 | def is_valid_hypercore_key(key: str) -> bool: | |
| 21 | """ | |
| 22 | Validate if a string is a valid Hypercore key. | |
| 23 | ||
| 24 | Hypercore keys are typically z-base-32 encoded and have a specific length. | |
| 25 | ||
| 26 | Args: | |
| 27 | key: The key to validate | |
| 28 | ||
| 29 | Returns: | |
| 30 | bool: True if the key is valid, False otherwise | |
| 31 | """ | |
| 32 | if not key or not isinstance(key, str): | |
| 33 | return False | |
| 34 | ||
| 35 | # Check if the key matches the z-base-32 character set and has the correct length | |
| 36 | return Z_BASE32_PATTERN.match(key) is not None and len(key) == HYPERCORE_KEY_LENGTH | |
| 37 | ||
| 38 | def extract_room_key_from_invite(invite_link: str) -> str: | |
| 39 | """ | |
| 40 | Extract the room key from a Keet invite link. | |
| 41 | ||
| 42 | Args: | |
| 43 | invite_link: The Keet invite link (e.g., "pear://keet/abc123...") | |
| 44 | ||
| 45 | Returns: | |
| 46 | str: The extracted room key | |
| 47 | """ | |
| 48 | if not invite_link: | |
| 49 | raise ValueError("Invite link cannot be empty") | |
| 50 | ||
| 51 | # Remove the prefix | |
| 52 | if invite_link.startswith(KEET_INVITE_PREFIX): | |
| 53 | room_key = invite_link[len(KEET_INVITE_PREFIX):] | |
| 54 | else: | |
| 55 | room_key = invite_link | |
| 56 | ||
| 57 | # Remove any additional path components or query parameters | |
| 58 | room_key = room_key.split("/")[0].split("?")[0] | |
| 59 | ||
| 60 | return room_key | |
| 61 | ||
| 62 | def normalize_hypercore_key(key: str) -> str: | |
| 63 | """ | |
| 64 | Normalize a Hypercore key to ensure it's in the correct format. | |
| 65 | ||
| 66 | This function attempts to fix common issues with Hypercore keys. | |
| 67 | ||
| 68 | Args: | |
| 69 | key: The key to normalize | |
| 70 | ||
| 71 | Returns: | |
| 72 | str: The normalized key | |
| 73 | """ | |
| 74 | # Remove any whitespace | |
| 75 | key = key.strip() | |
| 76 | ||
| 77 | # Remove the prefix if present | |
| 78 | if key.startswith(KEET_INVITE_PREFIX): | |
| 79 | key = key[len(KEET_INVITE_PREFIX):] | |
| 80 | ||
| 81 | # Remove any additional path components or query parameters | |
| 82 | key = key.split("/")[0].split("?")[0] | |
| 83 | ||
| 84 | # Ensure the key is lowercase (z-base-32 is case-insensitive but conventionally lowercase) | |
| 85 | key = key.lower() | |
| 86 | ||
| 87 | return key | |
| 88 | ||
| 89 | def run_node_script(script_path: str, args: List[str] = None) -> Tuple[bool, str]: | |
| 90 | """ | |
| 91 | Run a Node.js script and return the result. | |
| 92 | ||
| 93 | Args: | |
| 94 | script_path: Path to the Node.js script | |
| 95 | args: Additional arguments to pass to the script | |
| 96 | ||
| 97 | Returns: | |
| 98 | Tuple[bool, str]: A tuple containing a success flag and the output | |
| 99 | """ | |
| 100 | if args is None: | |
| 101 | args = [] | |
| 102 | ||
| 103 | cmd = ["node", str(script_path)] + args | |
| 104 | ||
| 105 | try: | |
| 106 | result = subprocess.run( | |
| 107 | cmd, | |
| 108 | capture_output=True, | |
| 109 | text=True, | |
| 110 | check=False # Don't raise an exception on non-zero exit code | |
| 111 | ) | |
| 112 | ||
| 113 | # Log the output | |
| 114 | for line in result.stdout.splitlines(): | |
| 115 | logger.info(line) | |
| 116 | ||
| 117 | for line in result.stderr.splitlines(): | |
| 118 | logger.error(line) | |
| 119 | ||
| 120 | return result.returncode == 0, result.stdout | |
| 121 | except Exception as e: | |
| 122 | logger.error(f"Error running Node.js script: {e}") | |
| 123 | return False, str(e) | |
| 124 | ||
| 125 | def get_default_storage_path() -> Path: | |
| 126 | """ | |
| 127 | Get the default storage path for Keet data. | |
| 128 | ||
| 129 | Returns: | |
| 130 | Path: The default storage path | |
| 131 | """ | |
| 132 | # Use the user's home directory | |
| 133 | home_dir = Path.home() | |
| 134 | ||
| 135 | # Create a .ollamatools directory if it doesn't exist | |
| 136 | ollamatools_dir = home_dir / ".ollamatools" | |
| 137 | ollamatools_dir.mkdir(exist_ok=True) | |
| 138 | ||
| 139 | # Create a keet directory if it doesn't exist | |
| 140 | keet_dir = ollamatools_dir / "keet" | |
| 141 | keet_dir.mkdir(exist_ok=True) | |
| 142 | ||
| 143 | return keet_dir |
| … | … | @@ -12,7 +12,7 @@ import fs from 'fs'; |
| 12 | 12 | import path from 'path'; |
| 13 | 13 | import { fileURLToPath } from 'url'; |
| 14 | 14 | import { parseArgs } from 'node:util'; |
| 15 | import { spawn } from 'child_process'; | |
| 15 | import fetch from 'node-fetch'; | |
| 16 | 16 | |
| 17 | 17 | // Get the directory name of the current module |
| 18 | 18 | const __filename = fileURLToPath(import.meta.url); |
| … | … | @@ -31,6 +31,14 @@ function log(level, message) { |
| 31 | 31 | console.log(`[${timestamp}] [${level}] ${message}`); |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | // More detailed logging for verbose mode | |
| 35 | function verboseLog(message, args) { | |
| 36 | if (args && args.verbose) { | |
| 37 | const timestamp = new Date().toISOString(); | |
| 38 | console.log(`[${timestamp}] [VERBOSE] ${message}`); | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 34 | 42 | // Import Keet dependencies or mock implementations |
| 35 | 43 | let KeetBackend, KeetCoreApi; |
| 36 | 44 | let KEET_AVAILABLE = false; |
| … | … | @@ -178,7 +186,9 @@ function parseArguments() { |
| 178 | 186 | storage: { type: 'string' }, |
| 179 | 187 | 'room-key': { type: 'string' }, |
| 180 | 188 | name: { type: 'string', default: 'KeetBot' }, |
| 181 | model: { type: 'string', default: 'llama3' }, | |
| 189 | model: { type: 'string', default: 'llama3.1:latest' }, | |
| 190 | 'ollama-host': { type: 'string', default: 'localhost:11434' }, | |
| 191 | prompt: { type: 'string' }, | |
| 182 | 192 | mock: { type: 'boolean', default: false }, |
| 183 | 193 | verbose: { type: 'boolean', default: false } |
| 184 | 194 | }; |
| … | … | @@ -194,75 +204,197 @@ function parseArguments() { |
| 194 | 204 | } |
| 195 | 205 | |
| 196 | 206 | /** |
| 197 | * Call Ollama to generate a response. | |
| 207 | * Ollama client for interacting with the Ollama API. | |
| 198 | 208 | */ |
| 199 | async function callOllama(message, model) { | |
| 200 | return new Promise((resolve, reject) => { | |
| 201 | const ollama = spawn('ollama', ['run', model], { | |
| 202 | stdio: ['pipe', 'pipe', 'pipe'] | |
| 203 | }); | |
| 204 | ||
| 205 | let response = ''; | |
| 206 | ||
| 207 | ollama.stdout.on('data', (data) => { | |
| 208 | response += data.toString(); | |
| 209 | }); | |
| 209 | class OllamaClient { | |
| 210 | constructor(host = 'localhost:11434', model = 'llama3.1:latest', systemPrompt = null) { | |
| 211 | this.host = host; | |
| 212 | this.model = model; | |
| 213 | this.systemPrompt = systemPrompt; | |
| 214 | this.baseUrl = `http://${host}`; | |
| 210 | 215 | |
| 211 | ollama.stderr.on('data', (data) => { | |
| 212 | log(logLevels.ERROR, `Ollama error: ${data.toString()}`); | |
| 213 | }); | |
| 214 | ||
| 215 | ollama.on('close', (code) => { | |
| 216 | if (code === 0) { | |
| 217 | resolve(response.trim()); | |
| 218 | } else { | |
| 219 | reject(new Error(`Ollama process exited with code ${code}`)); | |
| 216 | // User context tracking | |
| 217 | this.userContexts = new Map(); | |
| 218 | this.maxContextLength = 10; // Maximum number of messages to keep in context | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Call Ollama chat API to generate a response. | |
| 223 | */ | |
| 224 | async chat(message, userId, roomId) { | |
| 225 | try { | |
| 226 | const contextKey = `${userId}:${roomId}`; | |
| 227 | const messages = []; | |
| 228 | ||
| 229 | // Add system prompt if provided | |
| 230 | if (this.systemPrompt) { | |
| 231 | messages.push({ | |
| 232 | role: 'system', | |
| 233 | content: this.systemPrompt | |
| 234 | }); | |
| 220 | 235 | } |
| 221 | }); | |
| 222 | ||
| 223 | // Send the message to Ollama | |
| 224 | ollama.stdin.write(message); | |
| 225 | ollama.stdin.end(); | |
| 226 | }); | |
| 236 | ||
| 237 | // Add conversation context if available | |
| 238 | if (this.userContexts.has(contextKey)) { | |
| 239 | for (const ctxMsg of this.userContexts.get(contextKey)) { | |
| 240 | messages.push(ctxMsg); | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | // Add user message | |
| 245 | const userMessage = { | |
| 246 | role: 'user', | |
| 247 | content: message | |
| 248 | }; | |
| 249 | messages.push(userMessage); | |
| 250 | ||
| 251 | // Store user message in context | |
| 252 | if (!this.userContexts.has(contextKey)) { | |
| 253 | this.userContexts.set(contextKey, []); | |
| 254 | } | |
| 255 | this.userContexts.get(contextKey).push(userMessage); | |
| 256 | ||
| 257 | // Trim context if it gets too long | |
| 258 | if (this.userContexts.get(contextKey).length > this.maxContextLength) { | |
| 259 | this.userContexts.set( | |
| 260 | contextKey, | |
| 261 | this.userContexts.get(contextKey).slice(-this.maxContextLength) | |
| 262 | ); | |
| 263 | } | |
| 264 | ||
| 265 | // Make API request to Ollama | |
| 266 | const response = await fetch(`${this.baseUrl}/api/chat`, { | |
| 267 | method: 'POST', | |
| 268 | headers: { | |
| 269 | 'Content-Type': 'application/json' | |
| 270 | }, | |
| 271 | body: JSON.stringify({ | |
| 272 | model: this.model, | |
| 273 | messages: messages, | |
| 274 | stream: false | |
| 275 | }) | |
| 276 | }); | |
| 277 | ||
| 278 | if (!response.ok) { | |
| 279 | const errorText = await response.text(); | |
| 280 | throw new Error(`Ollama API error (${response.status}): ${errorText}`); | |
| 281 | } | |
| 282 | ||
| 283 | const data = await response.json(); | |
| 284 | const messageContent = data.message.content; | |
| 285 | ||
| 286 | // Store assistant response in context | |
| 287 | this.userContexts.get(contextKey).push({ | |
| 288 | role: 'assistant', | |
| 289 | content: messageContent | |
| 290 | }); | |
| 291 | ||
| 292 | // Trim context if it gets too long | |
| 293 | if (this.userContexts.get(contextKey).length > this.maxContextLength) { | |
| 294 | this.userContexts.set( | |
| 295 | contextKey, | |
| 296 | this.userContexts.get(contextKey).slice(-this.maxContextLength) | |
| 297 | ); | |
| 298 | } | |
| 299 | ||
| 300 | return messageContent; | |
| 301 | } catch (error) { | |
| 302 | log(logLevels.ERROR, `Error calling Ollama API: ${error.message}`); | |
| 303 | throw error; | |
| 304 | } | |
| 305 | } | |
| 306 | ||
| 307 | /** | |
| 308 | * List available models from Ollama. | |
| 309 | */ | |
| 310 | async listModels() { | |
| 311 | try { | |
| 312 | const response = await fetch(`${this.baseUrl}/api/tags`); | |
| 313 | ||
| 314 | if (!response.ok) { | |
| 315 | const errorText = await response.text(); | |
| 316 | throw new Error(`Ollama API error (${response.status}): ${errorText}`); | |
| 317 | } | |
| 318 | ||
| 319 | const data = await response.json(); | |
| 320 | return data.models.map(model => model.name); | |
| 321 | } catch (error) { | |
| 322 | log(logLevels.ERROR, `Error listing Ollama models: ${error.message}`); | |
| 323 | throw error; | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * Change the model being used. | |
| 329 | */ | |
| 330 | changeModel(model) { | |
| 331 | this.model = model; | |
| 332 | log(logLevels.INFO, `Changed Ollama model to: ${model}`); | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * Clear context for a specific user/room. | |
| 337 | */ | |
| 338 | clearContext(userId, roomId) { | |
| 339 | const contextKey = `${userId}:${roomId}`; | |
| 340 | this.userContexts.delete(contextKey); | |
| 341 | log(logLevels.INFO, `Cleared context for ${userId} in room ${roomId}`); | |
| 342 | } | |
| 227 | 343 | } |
| 228 | 344 | |
| 229 | 345 | /** |
| 230 | 346 | * Process new messages and respond using Ollama. |
| 231 | 347 | */ |
| 232 | async function processMessages(backend, roomId, lastProcessedTimestamp, model, verbose) { | |
| 348 | async function processMessages(backend, roomId, lastProcessedTimestamp, ollamaClient, verbose) { | |
| 233 | 349 | try { |
| 234 | 350 | // Get messages from the room |
| 351 | verboseLog(`Fetching messages from room ${roomId}...`, { verbose }); | |
| 235 | 352 | const messages = await backend.api.core.getChatMessages(roomId); |
| 353 | verboseLog(`Retrieved ${messages.length} total messages from room`, { verbose }); | |
| 354 | ||
| 355 | // Get identity for filtering | |
| 356 | const identity = await backend.api.core.getIdentity(); | |
| 357 | verboseLog(`Current identity: ${identity ? identity.publicKey : 'undefined'}`, { verbose }); | |
| 236 | 358 | |
| 237 | 359 | // Filter messages that are newer than the last processed timestamp |
| 238 | 360 | const newMessages = messages.filter(msg => { |
| 239 | 361 | // Skip our own messages |
| 240 | const identity = backend.api.core.getIdentity(); | |
| 241 | if (msg.author === identity.publicKey) { | |
| 362 | if (identity && identity.publicKey && msg.author === identity.publicKey) { | |
| 363 | verboseLog(`Skipping own message with ID: ${typeof msg.id === 'object' ? JSON.stringify(msg.id) : msg.id}`, { verbose }); | |
| 242 | 364 | return false; |
| 243 | 365 | } |
| 244 | 366 | |
| 245 | 367 | // Check if the message is newer than the last processed timestamp |
| 246 | return msg.timestamp > lastProcessedTimestamp; | |
| 368 | const isNewer = msg.timestamp > lastProcessedTimestamp; | |
| 369 | if (isNewer) { | |
| 370 | verboseLog(`Found new message with ID: ${typeof msg.id === 'object' ? JSON.stringify(msg.id) : msg.id}, timestamp: ${new Date(msg.timestamp).toISOString()}`, { verbose }); | |
| 371 | } | |
| 372 | return isNewer; | |
| 247 | 373 | }); |
| 248 | 374 | |
| 375 | verboseLog(`Found ${newMessages.length} new messages to process`, { verbose }); | |
| 376 | ||
| 249 | 377 | if (newMessages.length > 0) { |
| 250 | 378 | // Update the last processed timestamp |
| 251 | 379 | const latestTimestamp = Math.max(...newMessages.map(msg => msg.timestamp)); |
| 380 | verboseLog(`Updating last processed timestamp to: ${new Date(latestTimestamp).toISOString()}`, { verbose }); | |
| 252 | 381 | |
| 253 | 382 | // Process each new message |
| 254 | 383 | for (const message of newMessages) { |
| 255 | 384 | if (message.chat && message.chat.text) { |
| 256 | log(logLevels.INFO, `New message: ${message.chat.text}`); | |
| 385 | const authorId = message.author ? message.author.substring(0, 8) : 'unknown'; | |
| 386 | log(logLevels.INFO, `New message from ${authorId}: ${message.chat.text}`); | |
| 257 | 387 | |
| 258 | 388 | try { |
| 259 | 389 | // Generate a response using Ollama |
| 260 | log(logLevels.INFO, `Generating response using Ollama (model: ${model})...`); | |
| 261 | const response = await callOllama(message.chat.text, model); | |
| 390 | log(logLevels.INFO, `Generating response using Ollama (model: ${ollamaClient.model})...`); | |
| 391 | const response = await ollamaClient.chat(message.chat.text, message.author || 'unknown-user', roomId); | |
| 262 | 392 | |
| 263 | 393 | // Send the response back to the room |
| 264 | log(logLevels.INFO, `Sending response: ${response}`); | |
| 394 | log(logLevels.INFO, `Sending response to room ${roomId}: ${response}`); | |
| 395 | verboseLog(`Attempting to send message to room ${roomId}...`, { verbose }); | |
| 265 | 396 | await backend.api.core.addChatMessage(roomId, response); |
| 397 | log(logLevels.INFO, `✓ Message successfully sent to room`); | |
| 266 | 398 | } catch (error) { |
| 267 | 399 | log(logLevels.ERROR, `Failed to generate or send response: ${error.message}`); |
| 268 | 400 | } |
| … | … | @@ -270,6 +402,8 @@ async function processMessages(backend, roomId, lastProcessedTimestamp, model, v |
| 270 | 402 | } |
| 271 | 403 | |
| 272 | 404 | return latestTimestamp; |
| 405 | } else { | |
| 406 | verboseLog(`No new messages to process`, { verbose }); | |
| 273 | 407 | } |
| 274 | 408 | |
| 275 | 409 | return lastProcessedTimestamp; |
| … | … | @@ -338,6 +472,10 @@ async function main() { |
| 338 | 472 | |
| 339 | 473 | log(logLevels.INFO, `Current identity: ${JSON.stringify(identity)}`); |
| 340 | 474 | |
| 475 | // Initialize Ollama client | |
| 476 | log(logLevels.INFO, `Initializing Ollama client with host: ${args['ollama-host']}, model: ${args.model}`); | |
| 477 | const ollamaClient = new OllamaClient(args['ollama-host'], args.model, args.prompt); | |
| 478 | ||
| 341 | 479 | // Join the room if a room key is provided |
| 342 | 480 | let roomId; |
| 343 | 481 | if (args['room-key']) { |
| … | … | @@ -348,31 +486,49 @@ async function main() { |
| 348 | 486 | let normalizedRoomKey = roomKey; |
| 349 | 487 | if (roomKey.startsWith('pear://keet/')) { |
| 350 | 488 | normalizedRoomKey = roomKey.replace('pear://keet/', ''); |
| 489 | verboseLog(`Extracted room key from pear link: ${normalizedRoomKey}`, args); | |
| 351 | 490 | } |
| 352 | 491 | |
| 353 | 492 | // Check if there's a pairRoom method available |
| 354 | 493 | if (backend.api.core.pairRoom) { |
| 355 | 494 | log(logLevels.INFO, 'Using pairRoom method to join the room...'); |
| 495 | verboseLog(`Attempting to pair with room using key: ${normalizedRoomKey.substring(0, 16)}...`, args); | |
| 496 | ||
| 356 | 497 | try { |
| 498 | verboseLog(`Starting room pairing process...`, args); | |
| 357 | 499 | await backend.api.core.pairRoom(normalizedRoomKey); |
| 358 | log(logLevels.INFO, 'Successfully paired with the room'); | |
| 500 | log(logLevels.INFO, '✓ Successfully paired with the room'); | |
| 359 | 501 | roomId = normalizedRoomKey; |
| 502 | ||
| 503 | // Verify the room is in recent rooms after pairing | |
| 504 | const recentRooms = await backend.api.core.getRecentRooms(); | |
| 505 | const roomFound = recentRooms.some(room => room.roomId === normalizedRoomKey); | |
| 506 | ||
| 507 | if (roomFound) { | |
| 508 | verboseLog(`Room verified in recent rooms list`, args); | |
| 509 | } else { | |
| 510 | log(logLevels.WARNING, `Room paired but not found in recent rooms list - this may cause issues`); | |
| 511 | } | |
| 360 | 512 | } catch (error) { |
| 361 | 513 | log(logLevels.ERROR, `Failed to pair with room: ${error.message}`); |
| 514 | verboseLog(`Detailed error: ${error.stack || 'No stack trace available'}`, args); | |
| 362 | 515 | return 1; |
| 363 | 516 | } |
| 364 | 517 | } else { |
| 365 | 518 | log(logLevels.ERROR, 'pairRoom method not available'); |
| 519 | verboseLog(`Available API methods: ${Object.keys(backend.api.core).join(', ')}`, args); | |
| 366 | 520 | return 1; |
| 367 | 521 | } |
| 368 | 522 | } else { |
| 369 | 523 | // Get recent rooms |
| 370 | 524 | log(logLevels.INFO, 'No room key provided, checking recent rooms...'); |
| 371 | 525 | const recentRooms = await backend.api.core.getRecentRooms(); |
| 526 | verboseLog(`Found ${recentRooms.length} recent rooms`, args); | |
| 372 | 527 | |
| 373 | 528 | if (recentRooms.length > 0) { |
| 374 | 529 | roomId = recentRooms[0].roomId; |
| 375 | log(logLevels.INFO, `Using the most recent room: ${roomId}`); | |
| 530 | log(logLevels.INFO, `Using the most recent room: ${roomId.substring(0, 16)}...`); | |
| 531 | verboseLog(`Room details: ${JSON.stringify(recentRooms[0])}`, args); | |
| 376 | 532 | } else { |
| 377 | 533 | log(logLevels.ERROR, 'No room key provided and no recent rooms found'); |
| 378 | 534 | return 1; |
| … | … | @@ -389,25 +545,41 @@ async function main() { |
| 389 | 545 | try { |
| 390 | 546 | // Send an initial message to the room |
| 391 | 547 | const initialMessage = `Hello! I'm ${args.name}, a Keet bot powered by Ollama. I'm here to assist you.`; |
| 392 | await backend.api.core.addChatMessage(roomId, initialMessage); | |
| 393 | log(logLevels.INFO, 'Sent initial greeting message'); | |
| 548 | log(logLevels.INFO, `Sending initial greeting message to room ${roomId.substring(0, 16)}...`); | |
| 549 | verboseLog(`Full message: "${initialMessage}"`, args); | |
| 550 | ||
| 551 | try { | |
| 552 | verboseLog(`Attempting to send initial message...`, args); | |
| 553 | await backend.api.core.addChatMessage(roomId, initialMessage); | |
| 554 | log(logLevels.INFO, '✓ Initial greeting message sent successfully'); | |
| 555 | } catch (sendError) { | |
| 556 | log(logLevels.ERROR, `Failed to send initial message: ${sendError.message}`); | |
| 557 | verboseLog(`Detailed error: ${sendError.stack || 'No stack trace available'}`, args); | |
| 558 | } | |
| 394 | 559 | |
| 395 | 560 | // Main loop |
| 561 | log(logLevels.INFO, `Starting main message processing loop`); | |
| 562 | verboseLog(`Message polling interval: 2000ms`, args); | |
| 563 | ||
| 396 | 564 | while (true) { |
| 565 | verboseLog(`Checking for new messages in room ${roomId.substring(0, 16)}...`, args); | |
| 566 | ||
| 397 | 567 | // Process new messages |
| 398 | 568 | lastProcessedTimestamp = await processMessages( |
| 399 | 569 | backend, |
| 400 | 570 | roomId, |
| 401 | 571 | lastProcessedTimestamp, |
| 402 | args.model, | |
| 572 | ollamaClient, | |
| 403 | 573 | args.verbose |
| 404 | 574 | ); |
| 405 | 575 | |
| 406 | 576 | // Wait a bit before checking for new messages |
| 577 | verboseLog(`Waiting 2000ms before next message check...`, args); | |
| 407 | 578 | await new Promise(resolve => setTimeout(resolve, 2000)); |
| 408 | 579 | } |
| 409 | 580 | } catch (error) { |
| 410 | 581 | log(logLevels.ERROR, `Error in main loop: ${error.message}`); |
| 582 | verboseLog(`Detailed error: ${error.stack || 'No stack trace available'}`, args); | |
| 411 | 583 | |
| 412 | 584 | // Close the backend |
| 413 | 585 | log(logLevels.INFO, 'Closing backend...'); |