diff --git a/meshai/chunker.py b/meshai/chunker.py index 5004c70..3b6e6d5 100644 --- a/meshai/chunker.py +++ b/meshai/chunker.py @@ -13,6 +13,30 @@ import re logger = logging.getLogger(__name__) + +def strip_markdown(text: str) -> str: + """Remove markdown formatting from LLM output. + + LLMs often ignore 'no markdown' instructions. + This strips it before sending over LoRa. + """ + # Remove bold **text** + text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) + # Remove italic *text* + text = re.sub(r'\*(.*?)\*', r'\1', text) + # Remove headers (## Header) + text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) + # Remove bullet points at line start (- item or * item) + text = re.sub(r'^\s*[-*]\s+', '', text, flags=re.MULTILINE) + # Remove numbered lists at line start (1. item) + text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE) + # Remove code blocks + text = re.sub(r'```.*?```', '', text, flags=re.DOTALL) + # Remove inline code + text = re.sub(r'`(.*?)`', r'\1', text) + return text.strip() + + # Phrases that trigger continuation of a previous response CONTINUE_PHRASES = { "yes", "yeah", "yep", "yea", "sure", "ok", "okay", "go on", diff --git a/meshai/config.py b/meshai/config.py index d464386..8505517 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -35,10 +35,10 @@ class ConnectionConfig: class ResponseConfig: """Response behavior settings.""" - delay_min: float = 2.2 + delay_min: float = 3.0 delay_max: float = 3.0 - max_length: int = 150 - max_messages: int = 2 + max_length: int = 200 + max_messages: int = 3 @dataclass @@ -107,7 +107,9 @@ class LLMConfig: "the command list provided below. Don't dump lists unless asked.\n" "- You are part of the freq51 mesh.\n" "- When asked about yourself or commands, answer conversationally. Don't dump lists.\n" - "- You are part of the freq51 mesh in the Twin Falls, Idaho area." + "- You are part of the freq51 mesh in the Twin Falls, Idaho area.\n" + "- NEVER use markdown formatting (no bold, no asterisks, no bullet points, no numbered lists). Plain text only.\n" + "- NEVER say 'Want me to keep going?' -- the system handles continuation prompts automatically." ) use_system_prompt: bool = True # Toggle to disable sending system prompt web_search: bool = False # Enable web search (Open WebUI feature) diff --git a/meshai/connector.py b/meshai/connector.py index 77caa24..4f953a2 100644 --- a/meshai/connector.py +++ b/meshai/connector.py @@ -279,3 +279,78 @@ class MeshConnector: """ with self._lock: return self._node_names.get(node_id, node_id) + def send_and_wait_ack( + self, + text: str, + destination: Optional[str] = None, + channel: int = 0, + timeout: float = 30.0, + ) -> bool: + """Send a text message and wait for ACK. + + Args: + text: Message text + destination: Node ID for DM + channel: Channel index + timeout: Seconds to wait for ACK + + Returns: + True if ACK received, False if timeout + """ + if not self._interface: + logger.error("Cannot send: not connected") + return False + + ack_event = threading.Event() + ack_success = [False] + + def on_response(packet): + # Check if this is an ACK (not a NACK or error) + routing = packet.get("decoded", {}).get("routing", {}) + error_reason = routing.get("errorReason") + if error_reason is None or error_reason == "NONE": + ack_success[0] = True + else: + logger.warning(f"Message NACK: {error_reason}") + ack_event.set() + + try: + if destination: + if destination.startswith("!"): + dest_num = int(destination[1:], 16) + else: + dest_num = int(destination, 16) + + self._interface.sendText( + text=text, + destinationId=dest_num, + channelIndex=channel, + wantAck=True, + onResponse=on_response, + ) + else: + self._interface.sendText( + text=text, + destinationId=BROADCAST_NUM, + channelIndex=channel, + wantAck=True, + onResponse=on_response, + ) + + # Wait for ACK or timeout + received = ack_event.wait(timeout=timeout) + + if received and ack_success[0]: + logger.debug(f"ACK received for message to {destination or 'broadcast'}") + return True + elif received: + logger.warning(f"NACK received for message to {destination or 'broadcast'}") + return False + else: + logger.warning(f"ACK timeout ({timeout}s) for message to {destination or 'broadcast'}") + return False + + except Exception as e: + logger.error(f"Failed to send message: {e}") + return False + diff --git a/meshai/responder.py b/meshai/responder.py index 19054a8..ea63c6b 100644 --- a/meshai/responder.py +++ b/meshai/responder.py @@ -24,17 +24,11 @@ class Responder: destination: Optional[str] = None, channel: int = 0, ) -> bool: - """Send response messages with human-pacing delays. + """Send response messages with ACK waiting and retry. - Args: - messages: Pre-chunked messages list, or single string (legacy) - destination: Node ID for DM, or None for channel broadcast - channel: Channel to send on - - Returns: - True if all messages sent successfully + For DMs: waits for ACK before sending next message, retries once on failure. + For broadcasts: uses delay-based pacing (no ACK for broadcasts). """ - # Handle legacy single string if isinstance(messages, str): messages = [messages] @@ -42,24 +36,50 @@ class Responder: return True success = True + is_dm = destination is not None + for i, msg in enumerate(messages): - # Apply delay before sending (except first message) + # Randomized delay before sending (except first message) if i > 0: delay = random.uniform(self.config.delay_min, self.config.delay_max) await asyncio.sleep(delay) - # Send message - sent = self.connector.send_message( - text=msg, - destination=destination, - channel=channel, - ) + if is_dm and hasattr(self.connector, 'send_and_wait_ack'): + # DMs: send and wait for ACK + ack = await asyncio.get_event_loop().run_in_executor( + None, + self.connector.send_and_wait_ack, + msg, destination, channel, 30.0, + ) - if not sent: - logger.error(f"Failed to send message {i + 1}/{len(messages)}") - success = False - break + if not ack: + # Retry once + logger.warning(f"No ACK for msg {i+1}/{len(messages)}, retrying...") + await asyncio.sleep(random.uniform(3.0, 5.0)) + ack = await asyncio.get_event_loop().run_in_executor( + None, + self.connector.send_and_wait_ack, + msg, destination, channel, 30.0, + ) + if not ack: + logger.error(f"No ACK after retry for msg {i+1}/{len(messages)}, skipping remaining") + success = False + break - logger.debug(f"Sent message {i + 1}/{len(messages)}: {msg[:50]}...") + logger.debug(f"Sent+ACK msg {i+1}/{len(messages)}: {msg[:50]}...") + else: + # Broadcasts or fallback: fire and delay + sent = self.connector.send_message( + text=msg, + destination=destination, + channel=channel, + ) + if not sent: + logger.error(f"Failed to send message {i+1}/{len(messages)}") + success = False + break + + logger.debug(f"Sent msg {i+1}/{len(messages)}: {msg[:50]}...") return success + diff --git a/meshai/router.py b/meshai/router.py index 381a098..39058d1 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -110,6 +110,9 @@ RESPONSE STYLE: - When discussing problems, name the node and explain the impact - You CAN use 3-5 messages. Keep each sentence under 150 characters. - No markdown formatting - plain text only +- ABSOLUTELY NO markdown. No asterisks, no bold, no bullet points with * or -, no numbered lists with 1. 2. 3. Just plain text sentences. +- NEVER say "Want me to keep going?" — the message system adds this automatically when needed. If you say it yourself, users see it twice. +- When explaining "X/Y gateways" (like 7/7), explain that it means the node is visible to X out of Y data sources (Meshview and MeshMonitor instances that monitor the mesh). It does NOT mean infrastructure routers or regional gateways. QUESTION TYPES: - "How's the mesh?" -> Lead with composite score. Highlight 1-2 biggest issues by name. Summarize each region briefly. @@ -758,6 +761,10 @@ class MessageRouter: # Persist summary if one was created/updated await self._persist_summary(message.sender_id) + # Strip any markdown the LLM ignored instructions about + from .chunker import strip_markdown + response = strip_markdown(response) + # Chunk the response with sentence awareness messages, remaining = chunk_response( response,