diff --git a/.env.example b/.env.example index 3127240..582371e 100644 --- a/.env.example +++ b/.env.example @@ -42,17 +42,22 @@ LLM_API_KEY=your_api_key_here # Any other OpenAI-compatible endpoint # LLM_BASE_URL=https://openrouter.ai/api/v1 -# Bot Behavior Configuration -# Channel to listen to (0 for General, or specific channel name/number) -LISTEN_CHANNEL=0 -# Optional custom prompt file for domain-specific knowledge -# CUSTOM_PROMPT_FILE=prompts/custom.txt +# Optional LLM Configuration (Advanced) +# These have sensible defaults but can be customized if needed +# LLM_MAX_TOKENS=500 # Maximum tokens for LLM responses +# LLM_TEMPERATURE=0.7 # LLM temperature (0.0-2.0, lower = more focused) +# LLM_MAX_MESSAGE_LENGTH=120 # Maximum message length in characters +# Optional system prompt file (default: prompts/default.md) +# Use this to specify a custom system prompt file +# LLM_PROMPT_FILE=prompts/custom.md # MeshCore Configuration MESHCORE_CONNECTION_TYPE=mock # Node name - will be set as advertised name on startup # Bot will respond to DMs and @NodeName mentions in channels MESHCORE_NODE_NAME=MeshBot +# Channel to listen to (0 for General, or specific channel name/number) +MESHCORE_LISTEN_CHANNEL=0 # MESHCORE_PORT=/dev/ttyUSB0 # MESHCORE_HOST=192.168.1.100 # MESHCORE_BAUDRATE=115200 diff --git a/.gitignore b/.gitignore index 3968f42..1650f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -206,11 +206,13 @@ marimo/_static/ marimo/_lsp/ __marimo__/ -# MeshBot specific data files -# Ignore data directory except for system_prompt.txt -data/* -!data/system_prompt.txt -# Ignore node and channel data directories -data/nodes/ -data/channels/ +# MeshBot specific files +# Ignore all data files (user data, messages, adverts) +data/ + +# Ignore custom prompts but track default prompt +prompts/* +!prompts/default.md + +# Ignore logs logs/ diff --git a/AGENTS.md b/AGENTS.md index ac34ea0..995d89e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,10 +111,10 @@ pytest tests/test_basic.py -v 2. **AI Agent** (`src/meshbot/agent.py`) - Pydantic AI agent with rich tool set - - **Utility tools**: calculate, get_current_time, search_history, get_bot_status + - **Utility tools**: calculate, get_current_time, get_bot_status - **Fun tools**: roll_dice, flip_coin, random_number, magic_8ball - - **Network/mesh tools**: status_request, get_contacts, get_user_info, get_conversation_history - - **Query tools**: search_messages (for historical searches) + - **Network/mesh tools**: get_contacts, get_conversation_history + - **Query tools**: search_adverts, get_node_info, list_nodes (for historical network data) - Dependency injection system - Structured responses - Automatic message splitting for MeshCore length limits @@ -128,7 +128,7 @@ pytest tests/test_basic.py -v 3. **Memory System** (`src/meshbot/memory.py` + `src/meshbot/storage.py`) - File-based storage using text files and CSV - Data stored in `data/` directory - - System prompt in `data/system_prompt.txt` (editable) + - System prompt in `prompts/default.md` (editable, configurable via `LLM_PROMPT_FILE` env var or `--llm-prompt` CLI arg) - Network adverts in `data/adverts.csv` (append-only) - Per-user message history in `data/{pubkey}_messages.txt` - Per-user memories in `data/{pubkey}_memory.json` @@ -224,8 +224,8 @@ pytest tests/ -k "integration" -v # Test CLI with mock connection meshbot test user1 "hello" --meshcore-connection-type mock -# Test with custom prompt file -meshbot test user1 "hello" --custom-prompt my_prompt.txt --meshcore-connection-type mock +# Test with custom system prompt file +meshbot test user1 "hello" --llm-prompt my_prompt.md --meshcore-connection-type mock # Test with custom node name meshbot test user1 "hello" --meshcore-node-name TestBot --meshcore-connection-type mock @@ -253,8 +253,9 @@ meshbot/ │ ├── storage.py # File-based storage layer │ ├── config.py # Configuration management │ └── main.py # CLI entry point +├── prompts/ # System prompts directory +│ └── default.md # Default system prompt (tracked in git) ├── data/ # Data directory (auto-created) -│ ├── system_prompt.txt # System prompt (tracked in git) │ ├── adverts.csv # Network advertisements (gitignored) │ ├── {pubkey}_messages.txt # Message history per user (gitignored) │ └── {pubkey}_memory.json # User memories per user (gitignored) @@ -324,7 +325,6 @@ The agent currently has the following tools implemented in `src/meshbot/agent.py **Utility Tools**: - `calculate`: Perform mathematical calculations using Python's eval (safely) - `get_current_time`: Return current date and time -- `search_history`: Search conversation history for keywords - `get_bot_status`: Return bot uptime and connection status **Fun Tools**: @@ -334,13 +334,10 @@ The agent currently has the following tools implemented in `src/meshbot/agent.py - `magic_8ball`: Ask the magic 8-ball for wisdom **Network/Mesh Tools**: -- `status_request`: Send status request to a node (ping equivalent) - `get_contacts`: List all MeshCore contacts with names -- `get_user_info`: Get user statistics from chat logs - `get_conversation_history`: Retrieve recent messages with a user **Query Tools** (Historical Data): -- `search_messages`: Search messages across all conversations - `search_adverts`: Search advertisement history with filters (node_id, time range) - `get_node_info`: Get detailed info about a specific mesh node (status, activity, stats) - `list_nodes`: List all known nodes with filters (online_only, has_name) @@ -383,16 +380,16 @@ The agent includes network situational awareness implemented in `src/meshbot/mes ### Modifying Memory System The memory system uses file-based storage in the `data/` directory: -- **System prompt**: `data/system_prompt.txt` - editable system prompt for the agent +- **System prompt**: `prompts/default.md` - editable system prompt for the agent (Markdown format, configurable) - **Adverts file**: `data/adverts.csv` - network advertisements (append-only CSV) - **Message files**: `data/{pubkey}_messages.txt` - message history per user (pipe-delimited) - **Memory files**: `data/{pubkey}_memory.json` - user memories and metadata per user (JSON) -**File Formats** (see `src/meshbot/storage.py`): -- **system_prompt.txt**: Plain text, editable system prompt loaded on startup -- **adverts.csv**: CSV with columns: timestamp, node_id, node_name, signal_strength, details -- **{pubkey}_messages.txt**: Pipe-delimited: timestamp|message_type|role|content|sender -- **{pubkey}_memory.json**: JSON with fields: name, is_online, first_seen, last_seen, last_advert, total_adverts +**File Formats**: +- **prompts/default.md**: Markdown format system prompt loaded on startup (see `src/meshbot/agent.py`) +- **adverts.csv**: CSV with columns: timestamp, node_id, node_name, signal_strength, details (see `src/meshbot/storage.py`) +- **{pubkey}_messages.txt**: Pipe-delimited: timestamp|message_type|role|content|sender (see `src/meshbot/storage.py`) +- **{pubkey}_memory.json**: JSON with fields: name, is_online, first_seen, last_seen, last_advert, total_adverts (see `src/meshbot/storage.py`) **Efficiency**: - Adverts: Only reads last N lines using deque (efficient for large files) @@ -410,7 +407,7 @@ To modify: 2. Update `src/meshbot/memory.py` for interface changes 3. Update `src/meshbot/meshcore_interface.py` for event handling 4. Update `src/meshbot/agent.py` for new tools -5. Edit `data/system_prompt.txt` to customize agent behavior +5. Edit `prompts/default.md` to customize agent behavior (or create custom prompt and use `--llm-prompt` or `LLM_PROMPT_FILE` env var) 6. Update documentation if interface changes ## 🚨 Troubleshooting diff --git a/README.md b/README.md index 871203b..80550bd 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ docker run -it --rm \ - **📡 MeshCore Integration**: Communicates via MeshCore network (serial, TCP, BLE, or mock) - **🧠 Simple Memory System**: Text file-based chat logs (1000 lines per conversation) - **💬 Smart Messaging**: Automatic message splitting with length limits (configurable, default 120 chars) -- **🔧 Rich Tool System**: Utility tools (calculator, time, history) and fun tools (dice, coin, 8-ball, random numbers) +- **🔧 Rich Tool System**: Utility tools (calculator, time, bot status) and fun tools (dice, coin, 8-ball, random numbers) - **🌐 Network Awareness**: Real-time tracking of mesh network events (adverts, contacts, paths, status) - **👥 Contact Tracking**: Automatic node name discovery and mapping from mesh advertisements - **📊 Situational Context**: Network events and node names included in LLM context for awareness @@ -182,9 +182,9 @@ meshbot 3. **AI Agent** (`agent.py`) - Pydantic AI agent with rich tool set - - Utility tools (calculate, time, search history, bot status) + - Utility tools (calculate, time, bot status) - Fun tools (dice, coin, random numbers, magic 8-ball) - - Network/mesh tools (status requests, contact management) + - Network/mesh tools (contact management, conversation history, node queries) - Structured responses with message splitting - API request limits (max 5 per message) - Network context injection for situational awareness @@ -364,7 +364,6 @@ The agent has access to three categories of built-in tools: ### Utility Tools - **Calculate**: Perform mathematical calculations (e.g., "calculate 25 * 4 + 10") - **Get Current Time**: Get the current date and time -- **Search History**: Search conversation history for specific topics - **Get Bot Status**: Check bot uptime and connection status ### Fun Tools @@ -374,10 +373,12 @@ The agent has access to three categories of built-in tools: - **Magic 8-Ball**: Ask the magic 8-ball for wisdom ### Network/Mesh Tools -- **Status Request**: Send status request to nodes (ping equivalent) - **Get Contacts**: List available MeshCore contacts with their names -- **Get User Info**: Retrieve user statistics from chat logs -- **Conversation History**: Access recent messages with a user +- **Get Channel Messages**: Retrieve recent messages from a channel +- **Get User Messages**: Access recent private messages with a user +- **Get Node Info**: Get detailed information about a specific mesh node +- **List Nodes**: List all known nodes with optional filters +- **List Adverts**: Search advertisement history with filters ### Network Awareness The agent automatically receives context about: diff --git a/prompts/default.md b/prompts/default.md new file mode 100644 index 0000000..9669d4b --- /dev/null +++ b/prompts/default.md @@ -0,0 +1,34 @@ +# MeshBot System Prompt + +You are MeshBot, an AI assistant that communicates through the MeshCore network. You are helpful, concise, and knowledgeable. + +## MeshCore Network Limitations + +MeshCore is a simple text messaging system with some limitations: + +- Keep responses concise and clear (prefer under 200 chars, max 120) +- Use newlines for better readability when helpful +- NO emoji, but you CAN use basic punctuation like • — – for lists and separation +- Use plain text with good structure +- Be direct and helpful + +## Tool Usage Guidelines + +- Use tools ONLY when absolutely necessary - prefer direct responses +- Maximum 1-2 tool calls per message, avoid chains +- For simple questions, respond directly without tools +- IMPORTANT: When calling weather API, make the HTTP request INSIDE the tool, don't call the tool repeatedly +- CRITICAL: get_weather tool makes HTTP request automatically - call it ONCE only + +## Special Behaviors + +When users send 'ping', respond with 'pong' + +## Examples of Good Formatting + +``` +Status: Connected • 20 contacts online • 51 messages processed +Time: 14:30 • Date: 2025-01-15 +Result: Success • Data saved • Ready for next task +Nodes found: 12 online • 8 with names • 4 new today +``` diff --git a/src/meshbot/agent.py b/src/meshbot/agent.py index 4304d48..f93eef0 100644 --- a/src/meshbot/agent.py +++ b/src/meshbot/agent.py @@ -51,7 +51,7 @@ def __init__( data_dir: Optional[Path] = None, meshcore_connection_type: str = "mock", listen_channel: str = "0", - custom_prompt: Optional[str] = None, + system_prompt_file: Optional[Path] = None, base_url: Optional[str] = None, max_message_length: int = 120, node_name: Optional[str] = None, @@ -61,7 +61,7 @@ def __init__( self.data_dir = data_dir self.meshcore_connection_type = meshcore_connection_type self.listen_channel = listen_channel - self.custom_prompt = custom_prompt + self.system_prompt_file = system_prompt_file or Path("prompts/default.md") self.base_url = base_url self.max_message_length = max_message_length self.node_name = node_name @@ -129,56 +129,20 @@ async def initialize(self) -> None: ) await self.memory.load() - # Load system prompt from file - data_dir = self.data_dir or Path("data") - system_prompt_file = data_dir / "system_prompt.txt" - - # Create default system prompt if it doesn't exist - if not system_prompt_file.exists(): - data_dir.mkdir(parents=True, exist_ok=True) - default_prompt = ( - "You are MeshBot, an AI assistant that communicates through the MeshCore network. " - "You are helpful, concise, and knowledgeable. " - "MeshCore is a simple text messaging system with some limitations:\n" - f"- Keep responses concise and clear (prefer under 200 chars, max {self.max_message_length})\n" - "- Use newlines for better readability when helpful\n" - "- NO emoji, but you CAN use basic punctuation like • — – for lists and separation\n" - "- Use plain text with good structure\n" - "- Be direct and helpful\n" - "- Use tools ONLY when absolutely necessary - prefer direct responses\n" - "- Maximum 1-2 tool calls per message, avoid chains\n" - "- For simple questions, respond directly without tools\n" - "- IMPORTANT: When calling weather API, make the HTTP request INSIDE the tool, don't call the tool repeatedly\n" - "- CRITICAL: get_weather tool makes HTTP request automatically - call it ONCE only\n" - "When users send 'ping', respond with 'pong'\n" - "\n" - "Examples of good formatting:\n" - "Status: Connected • 20 contacts online • 51 messages processed\n" - "Time: 14:30 • Date: 2025-01-15\n" - "Result: Success • Data saved • Ready for next task\n" - "Nodes found: 12 online • 8 with names • 4 new today\n" - ) - with open(system_prompt_file, "w", encoding="utf-8") as f: - f.write(default_prompt) - logger.info(f"Created default system prompt: {system_prompt_file}") - # Load system prompt from file try: - with open(system_prompt_file, "r", encoding="utf-8") as f: - base_instructions = f.read() - logger.info(f"Loaded system prompt from: {system_prompt_file}") + if not self.system_prompt_file.exists(): + raise FileNotFoundError( + f"System prompt file not found: {self.system_prompt_file}" + ) + + with open(self.system_prompt_file, "r", encoding="utf-8") as f: + instructions = f.read() + logger.info(f"Loaded system prompt from: {self.system_prompt_file}") except Exception as e: logger.error(f"Error loading system prompt: {e}") - # Fall back to default - base_instructions = "You are MeshBot, an AI assistant that communicates through the MeshCore network." - - # Add custom prompt if provided - if self.custom_prompt: - instructions = ( - f"{base_instructions}\n\nAdditional Context:\n{self.custom_prompt}" - ) - else: - instructions = base_instructions + # Fall back to minimal default + instructions = "You are MeshBot, an AI assistant that communicates through the MeshCore network." # Set base URL for custom endpoints if provided if self.base_url: @@ -636,9 +600,9 @@ async def _handle_action( ) -> None: """Handle additional actions from the agent.""" try: - if action == "ping" and action_data and "destination" in action_data: - await self.meshcore.ping_node(action_data["destination"]) - # Add more action handlers as needed + # Action handling infrastructure reserved for future use + # Add action handlers as needed + pass except Exception as e: logger.error(f"Error handling action {action}: {e}") diff --git a/src/meshbot/config.py b/src/meshbot/config.py index 18d9804..bec6a8f 100644 --- a/src/meshbot/config.py +++ b/src/meshbot/config.py @@ -21,6 +21,9 @@ class MeshCoreConfig: node_name: Optional[str] = field( default_factory=lambda: os.getenv("MESHCORE_NODE_NAME", "MeshBot") ) + listen_channel: str = field( + default_factory=lambda: os.getenv("MESHCORE_LISTEN_CHANNEL", "0") + ) port: Optional[str] = field(default_factory=lambda: os.getenv("MESHCORE_PORT")) baudrate: int = field( default_factory=lambda: int(os.getenv("MESHCORE_BAUDRATE", "115200")) @@ -51,24 +54,25 @@ class AIConfig: api_key: Optional[str] = field(default_factory=lambda: os.getenv("LLM_API_KEY")) base_url: Optional[str] = field(default_factory=lambda: os.getenv("LLM_BASE_URL")) max_tokens: int = field( - default_factory=lambda: int(os.getenv("AI_MAX_TOKENS", "500")) + default_factory=lambda: int(os.getenv("LLM_MAX_TOKENS", "500")) ) temperature: float = field( - default_factory=lambda: float(os.getenv("AI_TEMPERATURE", "0.7")) - ) - listen_channel: str = field( - default_factory=lambda: os.getenv("LISTEN_CHANNEL", "0") + default_factory=lambda: float(os.getenv("LLM_TEMPERATURE", "0.7")) ) max_message_length: int = field( - default_factory=lambda: int(os.getenv("MAX_MESSAGE_LENGTH", "120")) + default_factory=lambda: int(os.getenv("LLM_MAX_MESSAGE_LENGTH", "120")) ) - custom_prompt_file: Optional[Path] = field(default=None) + system_prompt_file: Optional[Path] = field(default=None) def __post_init__(self) -> None: - """Post-initialization to handle custom_prompt_file.""" - prompt_file_env = os.getenv("CUSTOM_PROMPT_FILE") - if prompt_file_env and not self.custom_prompt_file: - self.custom_prompt_file = Path(prompt_file_env) + """Post-initialization to handle system prompt file.""" + # Handle system prompt file + system_prompt_env = os.getenv("LLM_PROMPT_FILE") + if system_prompt_env and not self.system_prompt_file: + self.system_prompt_file = Path(system_prompt_env) + elif not self.system_prompt_file: + # Default to prompts/default.md + self.system_prompt_file = Path("prompts/default.md") @dataclass diff --git a/src/meshbot/main.py b/src/meshbot/main.py index 34699cf..7123e10 100644 --- a/src/meshbot/main.py +++ b/src/meshbot/main.py @@ -53,6 +53,11 @@ def cli() -> None: @cli.command() @click.option("--model", "-m", help="AI model to use (e.g., openai:gpt-4o-mini)") +@click.option( + "--llm-prompt", + type=click.Path(exists=True, path_type=Path), + help="Path to system prompt file (default: prompts/default.md)", +) @click.option("--listen-channel", help="Channel to listen to (e.g., 0 for General)") @click.option( "--max-message-length", type=int, help="Maximum message length in characters" @@ -75,11 +80,6 @@ def cli() -> None: ) @click.option("--meshcore-timeout", type=int, help="MeshCore timeout in seconds") @click.option("--data-dir", type=click.Path(path_type=Path), help="Data directory path") -@click.option( - "--custom-prompt", - type=click.Path(exists=True, path_type=Path), - help="Path to custom prompt file", -) @click.option( "-v", "--verbose", @@ -89,6 +89,7 @@ def cli() -> None: @click.option("--log-file", type=click.Path(path_type=Path), help="Log file path") def run( model: Optional[str], + llm_prompt: Optional[Path], listen_channel: Optional[str], max_message_length: Optional[int], meshcore_connection_type: Optional[str], @@ -101,7 +102,6 @@ def run( meshcore_auto_reconnect: Optional[bool], meshcore_timeout: Optional[int], data_dir: Optional[Path], - custom_prompt: Optional[Path], verbose: int, log_file: Optional[Path], ) -> None: @@ -127,12 +127,12 @@ def run( # AI configuration if model: app_config.ai.model = model + if llm_prompt: + app_config.ai.system_prompt_file = llm_prompt if listen_channel: - app_config.ai.listen_channel = listen_channel + app_config.meshcore.listen_channel = listen_channel if max_message_length: app_config.ai.max_message_length = max_message_length - if custom_prompt: - app_config.ai.custom_prompt_file = custom_prompt # MeshCore configuration if meshcore_connection_type: @@ -162,24 +162,14 @@ def run( logger.error(f"Error loading configuration: {e}") sys.exit(1) - # Load custom prompt if provided - custom_prompt_content: Optional[str] = None - if app_config.ai.custom_prompt_file and app_config.ai.custom_prompt_file.exists(): - try: - with open(app_config.ai.custom_prompt_file, "r", encoding="utf-8") as f: - custom_prompt_content = f.read().strip() - logger.info(f"Loaded custom prompt from {app_config.ai.custom_prompt_file}") - except Exception as e: - logger.warning(f"Failed to load custom prompt: {e}") - # Create and run agent agent = MeshBotAgent( model=app_config.ai.model, data_dir=app_config.memory.storage_path, meshcore_connection_type=app_config.meshcore.connection_type, - listen_channel=app_config.ai.listen_channel, + listen_channel=app_config.meshcore.listen_channel, + system_prompt_file=app_config.ai.system_prompt_file, max_message_length=app_config.ai.max_message_length, - custom_prompt=custom_prompt_content, base_url=app_config.ai.base_url, node_name=app_config.meshcore.node_name, port=app_config.meshcore.port, @@ -236,6 +226,11 @@ async def run_agent(agent: MeshBotAgent) -> None: @click.argument("from_id") @click.argument("message") @click.option("--model", "-m", help="AI model to use (e.g., openai:gpt-4o-mini)") +@click.option( + "--llm-prompt", + type=click.Path(exists=True, path_type=Path), + help="Path to system prompt file (default: prompts/default.md)", +) @click.option("--listen-channel", help="Channel to listen to (e.g., 0 for General)") @click.option( "--max-message-length", type=int, help="Maximum message length in characters" @@ -259,11 +254,6 @@ async def run_agent(agent: MeshBotAgent) -> None: ) @click.option("--meshcore-timeout", type=int, help="MeshCore timeout in seconds") @click.option("--data-dir", type=click.Path(path_type=Path), help="Data directory path") -@click.option( - "--custom-prompt", - type=click.Path(exists=True, path_type=Path), - help="Path to custom prompt file", -) @click.option( "-v", "--verbose", @@ -274,6 +264,7 @@ def test( from_id: str, message: str, model: Optional[str], + llm_prompt: Optional[Path], listen_channel: Optional[str], max_message_length: Optional[int], meshcore_connection_type: str, @@ -286,7 +277,6 @@ def test( meshcore_auto_reconnect: Optional[bool], meshcore_timeout: Optional[int], data_dir: Optional[Path], - custom_prompt: Optional[Path], verbose: int, ) -> None: """Send a test message simulating a message from FROM_ID. @@ -311,12 +301,12 @@ def test( # AI configuration if model: app_config.ai.model = model + if llm_prompt: + app_config.ai.system_prompt_file = llm_prompt if listen_channel: - app_config.ai.listen_channel = listen_channel + app_config.meshcore.listen_channel = listen_channel if max_message_length: app_config.ai.max_message_length = max_message_length - if custom_prompt: - app_config.ai.custom_prompt_file = custom_prompt # MeshCore configuration app_config.meshcore.connection_type = meshcore_connection_type @@ -352,16 +342,6 @@ def test( setup_logging(level) logger = logging.getLogger(__name__) - # Load custom prompt if provided - custom_prompt_content: Optional[str] = None - if app_config.ai.custom_prompt_file and app_config.ai.custom_prompt_file.exists(): - try: - with open(app_config.ai.custom_prompt_file, "r", encoding="utf-8") as f: - custom_prompt_content = f.read().strip() - logger.info(f"Loaded custom prompt from {app_config.ai.custom_prompt_file}") - except Exception as e: - logger.warning(f"Failed to load custom prompt: {e}") - # Create and run test async def run_test(): """Run the test message.""" @@ -384,9 +364,9 @@ async def run_test(): model=app_config.ai.model, data_dir=app_config.memory.storage_path, meshcore_connection_type=app_config.meshcore.connection_type, - listen_channel=app_config.ai.listen_channel, + listen_channel=app_config.meshcore.listen_channel, + system_prompt_file=app_config.ai.system_prompt_file, max_message_length=app_config.ai.max_message_length, - custom_prompt=custom_prompt_content, base_url=app_config.ai.base_url, node_name=app_config.meshcore.node_name, port=app_config.meshcore.port, diff --git a/src/meshbot/storage/base.py b/src/meshbot/storage/base.py index e122a58..7dc7f15 100644 --- a/src/meshbot/storage/base.py +++ b/src/meshbot/storage/base.py @@ -53,16 +53,24 @@ def _get_node_prefix(self, pubkey: str) -> str: safe_key = "".join(c for c in pubkey if c.isalnum()) return safe_key[:8] - def _get_node_dir(self, pubkey: str) -> Path: - """Get the directory path for a node.""" + def _get_node_dir_path(self, pubkey: str) -> Path: + """Get the directory path for a node WITHOUT creating it.""" prefix = self._get_node_prefix(pubkey) - node_dir = self.nodes_dir / prefix + return self.nodes_dir / prefix + + def _get_node_dir(self, pubkey: str) -> Path: + """Get the directory path for a node and create it if needed.""" + node_dir = self._get_node_dir_path(pubkey) node_dir.mkdir(parents=True, exist_ok=True) return node_dir + def _get_channel_dir_path(self, channel_number: str) -> Path: + """Get the directory path for a channel WITHOUT creating it.""" + return self.channels_dir / channel_number + def _get_channel_dir(self, channel_number: str) -> Path: - """Get the directory path for a channel.""" - channel_dir = self.channels_dir / channel_number + """Get the directory path for a channel and create it if needed.""" + channel_dir = self._get_channel_dir_path(channel_number) channel_dir.mkdir(parents=True, exist_ok=True) return channel_dir @@ -70,7 +78,7 @@ def _get_messages_file( self, conversation_id: str, message_type: str = "direct" ) -> Path: """ - Get the messages file path for a conversation. + Get the messages file path for a conversation and create directory if needed. Args: conversation_id: Channel number or node pubkey @@ -86,10 +94,38 @@ def _get_messages_file( # Node: data/nodes/{pubkey_prefix}/messages.txt return self._get_node_dir(conversation_id) / "messages.txt" + def _get_messages_file_path( + self, conversation_id: str, message_type: str = "direct" + ) -> Path: + """ + Get the messages file path for a conversation WITHOUT creating directory. + + Args: + conversation_id: Channel number or node pubkey + message_type: "direct", "channel", or "broadcast" + + Returns: + Path to messages.txt file + """ + if message_type == "channel" or self._is_channel_id(conversation_id): + # Channel: data/channels/{number}/messages.txt + return self._get_channel_dir_path(conversation_id) / "messages.txt" + else: + # Node: data/nodes/{pubkey_prefix}/messages.txt + return self._get_node_dir_path(conversation_id) / "messages.txt" + def _get_user_messages_file(self, user_id: str) -> Path: - """Get the messages file path for a user (node).""" + """Get the messages file path for a user (node) and create directory if needed.""" return self._get_node_dir(user_id) / "messages.txt" + def _get_user_messages_file_path(self, user_id: str) -> Path: + """Get the messages file path for a user (node) WITHOUT creating directory.""" + return self._get_node_dir_path(user_id) / "messages.txt" + def _get_user_memory_file(self, user_id: str) -> Path: - """Get the memory file path for a user (node).""" + """Get the memory file path for a user (node) and create directory if needed.""" return self._get_node_dir(user_id) / "node.json" + + def _get_user_memory_file_path(self, user_id: str) -> Path: + """Get the memory file path for a user (node) WITHOUT creating directory.""" + return self._get_node_dir_path(user_id) / "node.json" diff --git a/src/meshbot/storage/messages.py b/src/meshbot/storage/messages.py index af83b8b..c1e16a8 100644 --- a/src/meshbot/storage/messages.py +++ b/src/meshbot/storage/messages.py @@ -73,11 +73,13 @@ async def get_conversation_messages( List of message dicts with keys: role, content, timestamp, sender """ try: - # Determine if it's a channel or node + # Use path-only version to avoid creating directory if self._is_channel_id(conversation_id): - messages_file = self._get_channel_dir(conversation_id) / "messages.txt" + messages_file = ( + self._get_channel_dir_path(conversation_id) / "messages.txt" + ) else: - messages_file = self._get_user_messages_file(conversation_id) + messages_file = self._get_user_messages_file_path(conversation_id) if not messages_file.exists(): return [] @@ -145,14 +147,14 @@ async def search_messages( # Determine which files to search if conversation_id: - # Determine if it's a channel or node + # Use path-only version to avoid creating directory if self._is_channel_id(conversation_id): files_to_search = [ - self._get_channel_dir(conversation_id) / "messages.txt" + self._get_channel_dir_path(conversation_id) / "messages.txt" ] else: files_to_search = [ - self._get_node_dir(conversation_id) / "messages.txt" + self._get_node_dir_path(conversation_id) / "messages.txt" ] else: # Search all message files (both nodes and channels) @@ -234,11 +236,13 @@ async def get_conversation_stats(self, conversation_id: str) -> Dict[str, Any]: Dict with total_messages, first_seen, last_seen """ try: - # Determine if it's a channel or node + # Use path-only version to avoid creating directory if self._is_channel_id(conversation_id): - messages_file = self._get_channel_dir(conversation_id) / "messages.txt" + messages_file = ( + self._get_channel_dir_path(conversation_id) / "messages.txt" + ) else: - messages_file = self._get_user_messages_file(conversation_id) + messages_file = self._get_user_messages_file_path(conversation_id) if not messages_file.exists(): return { diff --git a/src/meshbot/storage/nodes.py b/src/meshbot/storage/nodes.py index 7a96de6..d34277e 100644 --- a/src/meshbot/storage/nodes.py +++ b/src/meshbot/storage/nodes.py @@ -63,7 +63,9 @@ async def get_node_name(self, pubkey: str) -> Optional[str]: Friendly name if found, None otherwise """ try: - memory_file = self._get_user_memory_file(pubkey) + # Use path-only version to avoid creating directory + node_dir = self._get_node_dir_path(pubkey) + memory_file = node_dir / "node.json" if not memory_file.exists(): return None @@ -210,7 +212,9 @@ async def get_node(self, pubkey: str) -> Optional[Dict[str, Any]]: Node dict or None if not found """ try: - memory_file = self._get_user_memory_file(pubkey) + # Use path-only version to avoid creating directory + node_dir = self._get_node_dir_path(pubkey) + memory_file = node_dir / "node.json" if not memory_file.exists(): return None diff --git a/src/meshbot/tools/__init__.py b/src/meshbot/tools/__init__.py index afa5b2c..37c862d 100644 --- a/src/meshbot/tools/__init__.py +++ b/src/meshbot/tools/__init__.py @@ -9,17 +9,15 @@ def register_all_tools(agent: Any) -> None: Args: agent: The Pydantic AI agent to register tools with """ - from .conversation import register_conversation_tools from .fun import register_fun_tools - from .query import register_query_tools + from .nodes import register_node_tools from .utility import register_utility_tools from .weather import register_weather_tool # Register all tool groups - register_conversation_tools(agent) + register_node_tools(agent) register_utility_tools(agent) register_fun_tools(agent) - register_query_tools(agent) register_weather_tool(agent) diff --git a/src/meshbot/tools/conversation.py b/src/meshbot/tools/conversation.py deleted file mode 100644 index 5290349..0000000 --- a/src/meshbot/tools/conversation.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Conversation and user interaction tools.""" - -import logging -from typing import Any - -from pydantic_ai import RunContext - -from .logging_wrapper import create_logging_tool_decorator - -logger = logging.getLogger(__name__) - - -def register_conversation_tools(agent: Any) -> None: - """Register conversation-related tools. - - Args: - agent: The Pydantic AI agent to register tools with - """ - # Create logging tool decorator - tool = create_logging_tool_decorator(agent) - - @tool() - async def get_user_info(ctx: RunContext[Any], user_id: str) -> str: - """Get information about a user.""" - try: - memory = await ctx.deps.memory.get_user_memory(user_id) - - info = f"User: {memory.get('user_name') or user_id}\n" - info += f"Total messages: {memory.get('total_messages', 0)}\n" - info += f"First seen: {memory.get('first_seen', 'Never')}\n" - info += f"Last seen: {memory.get('last_seen', 'Never')}\n" - - return info - except Exception as e: - logger.error(f"Error getting user info: {e}") - return "Error retrieving user information." - - @tool() - async def status_request(ctx: RunContext[Any], destination: str) -> str: - """Send a status request to a MeshCore node (similar to ping).""" - try: - # Use send_statusreq instead of ping (which doesn't exist) - # This will request status from the destination node - success = await ctx.deps.meshcore.ping_node(destination) - result = ( - f"Status request to {destination}: {'Success' if success else 'Failed'}" - ) - return result - except Exception as e: - logger.error(f"Error sending status request: {e}") - return f"Status request to {destination} failed" - - @tool() - async def get_channel_messages( - ctx: RunContext[Any], channel: str = "0", limit: int = 5 - ) -> str: - """Get recent messages from a channel. - - Args: - channel: Channel number (default: "0" for main channel) - limit: Number of recent messages to retrieve (default: 5) - - Returns: - Recent channel messages in time order - """ - try: - # Get messages from channel - messages = await ctx.deps.memory.storage.get_conversation_messages( - conversation_id=channel, limit=limit - ) - if not messages: - return f"No messages in channel {channel}." - - response = f"Last {len(messages)} message(s) in channel {channel}:\n" - for msg in messages: - role = "User" if msg["role"] == "user" else "Bot" - response += f"{role}: {msg['content']}\n" - - return response.strip() - except Exception as e: - logger.error(f"Error getting channel messages: {e}") - return f"Error retrieving messages from channel {channel}." - - @tool() - async def get_user_messages( - ctx: RunContext[Any], user_id: str, limit: int = 5 - ) -> str: - """Get recent private messages with a specific user. - - Args: - user_id: User's public key (full or first 8-16 characters) - limit: Number of recent messages to retrieve (default: 5) - - Returns: - Recent private messages with the user in time order - """ - try: - messages = await ctx.deps.memory.storage.get_conversation_messages( - conversation_id=user_id, limit=limit - ) - if not messages: - return f"No conversation history with user {user_id[:16]}..." - - response = f"Last {len(messages)} message(s) with {user_id[:16]}:\n" - for msg in messages: - role = "User" if msg["role"] == "user" else "Bot" - response += f"{role}: {msg['content']}\n" - - return response.strip() - except Exception as e: - logger.error(f"Error getting user messages: {e}") - return f"Error retrieving messages with user {user_id[:16]}..." diff --git a/src/meshbot/tools/query.py b/src/meshbot/tools/nodes.py similarity index 75% rename from src/meshbot/tools/query.py rename to src/meshbot/tools/nodes.py index 42a9625..2e33f61 100644 --- a/src/meshbot/tools/query.py +++ b/src/meshbot/tools/nodes.py @@ -1,4 +1,4 @@ -"""Query tools for searching historical data.""" +"""Node and conversation tools for MeshBot.""" import logging from typing import Any, Optional @@ -10,8 +10,8 @@ logger = logging.getLogger(__name__) -def register_query_tools(agent: Any) -> None: - """Register query tools for historical data. +def register_node_tools(agent: Any) -> None: + """Register node-related tools (conversations, queries, and node information). Args: agent: The Pydantic AI agent to register tools with @@ -19,65 +19,70 @@ def register_query_tools(agent: Any) -> None: # Create logging tool decorator tool = create_logging_tool_decorator(agent) + # ========== Conversation Tools ========== + @tool() - async def search_messages( - ctx: RunContext[Any], - keyword: str, - hours_ago: Optional[int] = None, - limit: int = 20, + async def get_channel_messages( + ctx: RunContext[Any], channel: str = "0", limit: int = 5 ) -> str: - """Search messages across all conversations. + """Get recent messages from a channel. Args: - keyword: Keyword to search for (case-insensitive) - hours_ago: Only show messages from last N hours - limit: Maximum number of results (default 20, max 50) + channel: Channel number (default: "0" for main channel) + limit: Number of recent messages to retrieve (default: 5) Returns: - Formatted list of matching messages + Recent channel messages in time order """ try: - import time + # Get messages from channel + messages = await ctx.deps.memory.storage.get_conversation_messages( + conversation_id=channel, limit=limit + ) + if not messages: + return f"No messages in channel {channel}." - # Calculate timestamp filter if hours_ago is specified - since = None - if hours_ago is not None: - since = time.time() - (hours_ago * 3600) + response = f"Last {len(messages)} message(s) in channel {channel}:\n" + for msg in messages: + role = "User" if msg["role"] == "user" else "Bot" + response += f"{role}: {msg['content']}\n" - # Limit to max 50 results - limit = min(limit, 50) + return response.strip() + except Exception as e: + logger.error(f"Error getting channel messages: {e}") + return f"Error retrieving messages from channel {channel}." - # Query storage - messages = await ctx.deps.memory.storage.search_messages( - keyword=keyword, - since=since, - limit=limit, - ) + @tool() + async def get_user_messages( + ctx: RunContext[Any], user_id: str, limit: int = 5 + ) -> str: + """Get recent private messages with a specific user. - if not messages: - time_filter = f" in last {hours_ago}h" if hours_ago else "" - return f"No messages found containing '{keyword}'{time_filter}" + Args: + user_id: User's public key (full or first 8-16 characters) + limit: Number of recent messages to retrieve (default: 5) - # Format results - from datetime import datetime + Returns: + Recent private messages with the user in time order + """ + try: + messages = await ctx.deps.memory.storage.get_conversation_messages( + conversation_id=user_id, limit=limit + ) + if not messages: + return f"No conversation history with user {user_id[:16]}..." - result = f"Found {len(messages)} message(s) with '{keyword}':\n" + response = f"Last {len(messages)} message(s) with {user_id[:16]}:\n" for msg in messages: - timestamp = datetime.fromtimestamp(msg["timestamp"]) - time_str = timestamp.strftime("%Y-%m-%d %H:%M") role = "User" if msg["role"] == "user" else "Bot" - content_preview = ( - msg["content"][:60] + "..." - if len(msg["content"]) > 60 - else msg["content"] - ) - result += f"[{time_str}] {role}: {content_preview}\n" - - return result.strip() + response += f"{role}: {msg['content']}\n" + return response.strip() except Exception as e: - logger.error(f"Error searching messages: {e}") - return "Error searching messages" + logger.error(f"Error getting user messages: {e}") + return f"Error retrieving messages with user {user_id[:16]}..." + + # ========== Query Tools ========== @tool() async def list_adverts( diff --git a/src/meshbot/tools/utility.py b/src/meshbot/tools/utility.py index c39d905..fc241cd 100644 --- a/src/meshbot/tools/utility.py +++ b/src/meshbot/tools/utility.py @@ -90,54 +90,6 @@ async def get_current_time(ctx: RunContext[Any], format: str = "human") -> str: logger.error(f"Error getting time: {e}") return "Error retrieving current time" - @tool() - async def search_history( - ctx: RunContext[Any], - user_id: str, - keyword: str, - limit: int = 5, - ) -> str: - """Search conversation history for messages containing a keyword. - - Args: - user_id: User/channel ID to search - keyword: Keyword to search for (case-insensitive) - limit: Maximum number of results to return - - Returns: - Matching messages or no results message - """ - try: - # Get full history - history = await ctx.deps.memory.get_conversation_history(user_id, limit=100) - - if not history: - return f"No conversation history with {user_id}" - - # Search for keyword (case-insensitive) - keyword_lower = keyword.lower() - matches = [ - msg for msg in history if keyword_lower in msg["content"].lower() - ][:limit] - - if not matches: - return f"No messages found containing '{keyword}'" - - response = f"Found {len(matches)} message(s) with '{keyword}':\n" - for msg in matches: - role = "User" if msg["role"] == "user" else "Assistant" - content_preview = ( - msg["content"][:60] + "..." - if len(msg["content"]) > 60 - else msg["content"] - ) - response += f"{role}: {content_preview}\n" - - return response.strip() - except Exception as e: - logger.error(f"Error searching history: {e}") - return "Error searching conversation history" - @tool() async def get_bot_status(ctx: RunContext[Any]) -> str: """Get current bot status and statistics.