feat: !health returns list for separate LoRa messages, partial name distance matching

- 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 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-06 00:11:57 +00:00
commit 7670b7c0b9
3 changed files with 81 additions and 38 deletions

View file

@ -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:

View file

@ -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."""

View file

@ -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.