From 7670b7c0b9e9c4da775068de1b1d02b1eaab9b76 Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Wed, 6 May 2026 00:11:57 +0000 Subject: [PATCH] feat: !health returns list for separate LoRa messages, partial name distance matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_lora_compact returns list[str] instead of str - Each line is a separate LoRa message (no chunking needed) - main.py handles list responses from commands - _try_compute_distance supports partial names (TVM Pearl → TVM Pearl Relay) - Ambiguous names detected (TVM → asks which node) - Max message size: 54 bytes (well under 228 byte limit) Co-Authored-By: Claude Opus 4.5 --- meshai/main.py | 24 +++++++++++--------- meshai/mesh_reporter.py | 49 ++++++++++++++++++++--------------------- meshai/router.py | 46 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/meshai/main.py b/meshai/main.py index e8d7d6d..2c13e20 100644 --- a/meshai/main.py +++ b/meshai/main.py @@ -344,17 +344,19 @@ class MeshAI: # Determine response if result.route_type == RouteType.COMMAND: - # Chunk command output same as LLM responses - from .chunker import chunk_response - raw = result.response if isinstance(result.response, str) else str(result.response) - messages, remaining = chunk_response( - raw, - max_chars=self.config.response.max_length, - max_messages=self.config.response.max_messages, - ) - # Store remaining for continuation - if remaining: - self.router.continuations.store(message.sender_id, remaining) + if isinstance(result.response, list): + # Command returned pre-split messages — send directly + messages = result.response + else: + # Single string — chunk it + from .chunker import chunk_response + messages, remaining = chunk_response( + result.response, + max_chars=self.config.response.max_length, + max_messages=self.config.response.max_messages, + ) + if remaining: + self.router.continuations.store(message.sender_id, remaining) elif result.route_type == RouteType.LLM: messages = await self.router.generate_llm_response(message, result.query) else: diff --git a/meshai/mesh_reporter.py b/meshai/mesh_reporter.py index 799bdc4..e15ea1a 100644 --- a/meshai/mesh_reporter.py +++ b/meshai/mesh_reporter.py @@ -1503,24 +1503,24 @@ class MeshReporter: return recs - def build_lora_compact(self, scope: str, scope_value: str = None) -> str: - """Build LoRa-optimized summary with personality for !health command.""" + def build_lora_compact(self, scope: str, scope_value: str = None) -> list[str]: + """Build LoRa-optimized summary. Returns list of messages (one per line).""" health = self.health_engine.mesh_health if not health: - return "📡 No mesh data yet." + return ["📡 No mesh data yet."] if scope == "region" and scope_value: region = self._find_region(scope_value) if not region: - return f"Region '{scope_value}' not found." + return [f"Region '{scope_value}' not found."] return self._region_lora_compact(region) if scope == "node" and scope_value: - return self.build_node_compact(scope_value) + return [self.build_node_compact(scope_value)] s = health.score - # Color dot — no numbers shown + # Color dot — no numbers if s.composite >= 100: dot = "🔵" mood = "perfect" @@ -1534,8 +1534,7 @@ class MeshReporter: dot = "🔴" mood = "critical" - # Each line ends with period so chunker sends each as separate message - lines = [f"📡 Mesh {dot} {mood}."] + lines = [f"📡 Mesh {dot} {mood}"] offline_infra = [n for n in health.nodes.values() if n.is_infrastructure and not n.is_online] if offline_infra: @@ -1543,10 +1542,10 @@ class MeshReporter: (n.long_name or n.short_name or str(n.node_num)) for n in offline_infra[:3] ) more = f" +{len(offline_infra)-3}" if len(offline_infra) > 3 else "" - lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up.") - lines.append(f"❌ Down: {offline_names}{more}.") + lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up") + lines.append(f"❌ Down: {offline_names}{more}") else: - lines.append(f"🏗️ All {s.infra_total} routers up ✅.") + lines.append(f"🏗️ All {s.infra_total} routers up ✅") nodes_with_gw = [n for n in health.nodes.values() if n.avg_gateways is not None] if nodes_with_gw: @@ -1554,30 +1553,30 @@ class MeshReporter: full = sum(1 for n in nodes_with_gw if n.avg_gateways >= total_sources) single = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.0) if single > 0: - lines.append(f"📶 {full} nodes full coverage, {single} on thin ice with 1 gw.") + lines.append(f"📶 {full} nodes full coverage, {single} on thin ice with 1 gw") else: - lines.append(f"📶 {full} nodes full coverage ✅.") + lines.append(f"📶 {full} nodes full coverage ✅") high_util = [n for n in health.nodes.values() if n.channel_utilization is not None and n.channel_utilization > 15] if high_util: worst = max(high_util, key=lambda n: n.channel_utilization) worst_name = worst.long_name or worst.short_name or str(worst.node_num) - lines.append(f"🔥 {worst_name} running hot at {worst.channel_utilization:.0f}% util.") + lines.append(f"🔥 {worst_name} running hot at {worst.channel_utilization:.0f}% util") infra_nodes = [n for n in health.nodes.values() if n.is_infrastructure] bat_low = [n for n in infra_nodes if n.battery_percent is not None and 0 < n.battery_percent < 20] if bat_low: low_names = ", ".join(n.long_name or n.short_name or str(n.node_num) for n in bat_low) - lines.append(f"🔋 ⚠️ {low_names} low battery.") + lines.append(f"🔋 ⚠️ {low_names} low battery") else: - lines.append(f"🔋 All infra powered up ✅.") + lines.append(f"🔋 All infra powered up ✅") env_nodes = [n for n in health.nodes.values() if n.has_environment_sensor] if env_nodes: temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)] if temps: - lines.append(f"🌡️ {min(temps):.0f}–{max(temps):.0f}°C across {len(env_nodes)} sensors.") + lines.append(f"🌡️ {min(temps):.0f}–{max(temps):.0f}°C across {len(env_nodes)} sensors") region_parts = [] for region in health.regions: @@ -1595,12 +1594,12 @@ class MeshReporter: else: region_parts.append(f"{name} 🔴") if region_parts: - lines.append(" | ".join(region_parts) + ".") + lines.append(" | ".join(region_parts)) - return "\n".join(lines) + return lines - def _region_lora_compact(self, region) -> str: - """Compact region display for !region [name].""" + def _region_lora_compact(self, region) -> list[str]: + """Compact region display. Returns list of messages.""" rs = region.score context = self._region_context(region.name) name = context.split("(")[0].strip() if context else region.name @@ -1614,8 +1613,8 @@ class MeshReporter: else: dot = "🔴" - lines = [f"📡 {name} {dot}."] - lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | 📡 {rs.util_percent:.0f}% util.") + lines = [f"📡 {name} {dot}"] + lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | {rs.util_percent:.0f}% util") offline = [] for nid_str in region.node_ids: @@ -1627,9 +1626,9 @@ class MeshReporter: if node and node.is_infrastructure and not node.is_online: offline.append(node.long_name or node.short_name or str(nid)) if offline: - lines.append(f"❌ Down: {', '.join(offline)}.") + lines.append(f"❌ Down: {', '.join(offline)}") - return "\n".join(lines) + return lines def list_regions_compact(self) -> str: """List all regions with scores for !region command.""" diff --git a/meshai/router.py b/meshai/router.py index 9958768..381a098 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -476,13 +476,21 @@ class MessageRouter: query_lower = query.lower() - # Build name -> node lookup + # Build name -> node lookup (include partial long_name matches) node_names = {} for node in health.nodes.values(): if node.short_name: node_names[node.short_name.lower()] = node if node.long_name: - node_names[node.long_name.lower()] = node + full = node.long_name.lower() + node_names[full] = node + # Add partial matches: "TVM Pearl Relay" also matches "TVM Pearl" + words = full.split() + if len(words) >= 2: + for i in range(2, len(words) + 1): + partial = " ".join(words[:i]) + if partial not in node_names: + node_names[partial] = node # AIDA aliases aida_node = health.nodes.get(0x27780c47) @@ -492,6 +500,7 @@ class MessageRouter: # Find mentioned nodes (longest names first) found_nodes = [] + for name in sorted(node_names.keys(), key=len, reverse=True): if name in query_lower and len(name) >= 2: node = node_names[name] @@ -500,6 +509,38 @@ class MessageRouter: if len(found_nodes) >= 2: break + # If we only found one or zero nodes, check for ambiguous short terms + if len(found_nodes) < 2: + query_words = query_lower.replace("?", "").replace("!", "").split() + candidate_terms = list(query_words) + for i in range(len(query_words) - 1): + candidate_terms.append(f"{query_words[i]} {query_words[i+1]}") + + skip_words = {"how", "far", "is", "from", "the", "to", "and", "between", "what", + "distance", "away", "are", "apart", "tell", "me", "about", "a", "an"} + + for term in candidate_terms: + if term in skip_words or len(term) < 2: + continue + matches = [] + seen_nums = set() + for node in health.nodes.values(): + if node.node_num in seen_nums: + continue + name_lower = (node.long_name or "").lower() + short_lower = (node.short_name or "").lower() + if term in name_lower or term == short_lower: + matches.append(node) + seen_nums.add(node.node_num) + + if len(matches) > 1: + names = [f" - {n.long_name or n.short_name} ({n.short_name})" + for n in matches[:6]] + return ( + f"AMBIGUOUS: '{term}' matches multiple nodes. " + f"Ask the user which one they mean:\n" + "\n".join(names) + ) + if len(found_nodes) == 2: return self.mesh_reporter.build_distance( str(found_nodes[0].node_num), @@ -513,6 +554,7 @@ class MessageRouter: return "" + async def generate_llm_response(self, message: MeshMessage, query: str) -> str: """Generate LLM response for a message.