From aef78996a9a676aebd9d029974364f41f7a9ee87 Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Tue, 5 May 2026 15:33:14 +0000 Subject: [PATCH] feat: Named single-gw nodes, infra-only monitoring, commands overhaul Coverage: - Single-gateway infra nodes named as critical risks per region - Client single-gw nodes counted but not individually named - Mesh-wide single-gw infra summary Monitoring rules by node type: - Infrastructure: full detail - battery, offline, coverage, neighbors, hardware - Clients causing problems: named - high util, top senders - Clients otherwise: counted per region, not individually tracked - POWER breakdown now infra-only Commands: - Removed hardcoded command list from config.py system_prompt - Dynamic command list in router.py from dispatcher (only enabled commands) - MeshMonitor commands no longer listed as MeshAI commands - !help overhaul: grouped by category, per-command detailed help - LLM explicitly told to only mention listed commands Co-Authored-By: Claude Opus 4.5 --- meshai/commands/help.py | 128 +++++++++++++++++++++++++++++++++++++--- meshai/config.py | 14 +---- meshai/mesh_reporter.py | 31 +++++++++- meshai/router.py | 27 ++++++++- 4 files changed, 178 insertions(+), 22 deletions(-) diff --git a/meshai/commands/help.py b/meshai/commands/help.py index d4d2294..71e30dc 100644 --- a/meshai/commands/help.py +++ b/meshai/commands/help.py @@ -8,18 +8,132 @@ class HelpCommand(CommandHandler): name = "help" description = "Show available commands" - usage = "!help" + usage = "!help [command]" def __init__(self, dispatcher): self._dispatcher = dispatcher async def execute(self, args: str, context: CommandContext) -> str: - """List all available commands.""" + if args and args.strip(): + return self._command_help(args.strip().lower()) + return self._list_all() + + def _list_all(self) -> str: + """List all commands grouped by category.""" commands = self._dispatcher.get_commands() - # Build compact help text - lines = ["Commands:"] - for cmd in sorted(commands, key=lambda c: c.name): - lines.append(f"!{cmd.name} - {cmd.description}") + # Deduplicate aliases + seen = set() + unique = [] + for cmd in commands: + if cmd.name.lower() not in seen: + seen.add(cmd.name.lower()) + unique.append(cmd) - return " | ".join(lines) + # Group by category + health_names = {"health", "region", "neighbors"} + sub_names = {"sub", "unsub", "mysubs"} + + health_cmds = [c for c in unique if c.name.lower() in health_names] + sub_cmds = [c for c in unique if c.name.lower() in sub_names] + other_cmds = [c for c in unique if c.name.lower() not in health_names and c.name.lower() not in sub_names and c.name.lower() != "help"] + + lines = ["Commands:"] + + if health_cmds: + lines.append("") + lines.append("Mesh Health:") + for c in sorted(health_cmds, key=lambda x: x.name): + lines.append(f" !{c.name} - {c.description}") + + if sub_cmds: + lines.append("") + lines.append("Subscriptions:") + for c in sorted(sub_cmds, key=lambda x: x.name): + lines.append(f" !{c.name} - {c.description}") + + if other_cmds: + lines.append("") + lines.append("Other:") + for c in sorted(other_cmds, key=lambda x: x.name): + lines.append(f" !{c.name} - {c.description}") + + lines.append("") + lines.append("!help [cmd] for details") + lines.append("Or just ask me naturally!") + + return "\n".join(lines) + + def _command_help(self, cmd_name: str) -> str: + """Detailed help for a specific command.""" + aliases = { + "sub": "sub", "subscribe": "sub", "subscription": "sub", "subscriptions": "sub", + "unsub": "unsub", "unsubscribe": "unsub", + "mysubs": "mysubs", "subs": "mysubs", + "health": "health", "mesh": "health", + "region": "region", "reg": "region", + "neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors", + "clear": "clear", "reset": "clear", + } + resolved = aliases.get(cmd_name, cmd_name) + + # Check if this command is actually registered + registered = {c.name.lower() for c in self._dispatcher.get_commands()} + + texts = { + "sub": ( + "Subscribe to Reports & Alerts\n\n" + "Daily report:\n" + " !sub daily 6pm\n" + " !sub daily 7:30am region SCID\n" + " !sub daily 6pm node MHR\n\n" + "Weekly digest:\n" + " !sub weekly 8am sun\n\n" + "Alerts (instant DM on issues):\n" + " !sub alerts\n" + " !sub alerts region Wood River\n\n" + "Time: 6pm, 6:30pm, 1830, 18:30\n" + "Regions: SCID, SWID, Magic Valley, Twin Falls\n\n" + "Manage:\n" + " !mysubs - list yours\n" + " !unsub daily - remove daily\n" + " !unsub all - remove everything" + ), + "unsub": ( + "Unsubscribe\n\n" + " !unsub daily - remove daily report\n" + " !unsub weekly - remove weekly digest\n" + " !unsub alerts - remove alerts\n" + " !unsub all - remove everything" + ), + "mysubs": "!mysubs - list your active subscriptions", + "health": ( + "Mesh Health\n\n" + " !health - 5-pillar health summary\n" + " !health now - force fresh data\n\n" + "Or ask: 'how's the mesh?'" + ), + "region": ( + "Region Info\n\n" + " !region - list all regions\n" + " !region SCID - South Central ID\n" + " !region boise - South Western ID" + ), + "neighbors": ( + "Neighbors\n\n" + " !neighbors MHR - infra neighbors + signal\n" + " !nb T2T - alias" + ), + "clear": "!clear or !reset - clears conversation history", + "ping": "!ping - connectivity test, responds with pong", + "status": "!status - shows version, uptime, message count", + "weather": "!weather [location] - weather lookup", + } + + help_text = texts.get(resolved) + if help_text and resolved in registered: + return help_text + elif help_text and resolved not in registered: + return f"The !{resolved} command is not currently enabled." + else: + return f"No help available for '{cmd_name}'. Try !help" diff --git a/meshai/config.py b/meshai/config.py index 2ed33ca..83547d1 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -96,23 +96,15 @@ class LLMConfig: timeout: int = 30 system_prompt: str = ( - "YOUR COMMANDS (handled directly by you via DM):\n" - "!help — List available commands.\n" - "!ping — Connectivity test, responds with pong.\n" - "!status — Shows your version, uptime, user count, and message count.\n" - "!weather [location] — Weather lookup using Open-Meteo API.\n" - "!reset — Clears conversation history and memory.\n" - "!clear — Same as !reset.\n\n" - "YOUR ARCHITECTURE: Modular Python — pluggable LLM backends (OpenAI, Anthropic, " - "Google, local), per-user SQLite conversation history, rolling summary memory, " - "passive mesh context buffer (observes channel traffic), smart chunking for LoRa " - "message limits, prompt injection defense, advBBS filtering.\n\n" "RESPONSE RULES:\n" "- For casual conversation, keep responses brief (1-2 sentences).\n" "- For mesh health questions, give detailed data-driven responses.\n" "- Be concise but friendly. No markdown formatting.\n" "- If asked about mesh activity and no recent traffic is shown, say you haven't " "observed any yet.\n" + "- When asked about yourself or commands, answer conversationally based on " + "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." ) diff --git a/meshai/mesh_reporter.py b/meshai/mesh_reporter.py index 7173915..0a7845a 100644 --- a/meshai/mesh_reporter.py +++ b/meshai/mesh_reporter.py @@ -268,6 +268,19 @@ class MeshReporter: total_gw = len(self.data_store._sources) lines.append(f" Coverage: {len(region_gw)} nodes, avg {avg_gw:.1f}/{total_gw} gw, {single} single-gateway") + # Name single-gateway INFRASTRUCTURE nodes (critical risks) + if single > 0: + single_gw_nodes = [n for n in region_gw if n.avg_gateways is not None and n.avg_gateways <= 1.0] + single_infra = [n for n in single_gw_nodes if n.is_infrastructure] + single_clients = len(single_gw_nodes) - len(single_infra) + + if single_infra: + for sgn in single_infra: + sgn_name = _node_display_name(sgn.long_name, sgn.short_name, str(sgn.node_num)) + lines.append(f" INFRA at risk: {sgn_name} - only 1 gateway") + if single_clients > 0: + lines.append(f" + {single_clients} client nodes at single gateway") + env_in_region = [] for nid_str in region.node_ids: try: @@ -306,7 +319,7 @@ class MeshReporter: lines.append(f" [OFFLINE] {name}: infra offline for {age}, {region}") critical_bat = [n for n in health.nodes.values() - if n.battery_percent is not None and 0 < n.battery_percent < 10] + if n.is_infrastructure and n.battery_percent is not None and 0 < n.battery_percent < 10] if critical_bat: problems_found = True for node in sorted(critical_bat, key=lambda n: n.battery_percent): @@ -316,7 +329,7 @@ class MeshReporter: lines.append(f" [CRITICAL] {name}: battery {node.battery_percent:.0f}%{trend}, {region}") low_bat = [n for n in health.nodes.values() - if n.battery_percent is not None and 10 <= n.battery_percent < 20] + if n.is_infrastructure and n.battery_percent is not None and 10 <= n.battery_percent < 20] if low_bat: problems_found = True for node in sorted(low_bat, key=lambda n: n.battery_percent)[:5]: @@ -375,6 +388,16 @@ class MeshReporter: lines.append(f" Full coverage ({total_sources}/{total_sources} gw): {full_gw} nodes") lines.append(f" Single gateway (1/{total_sources} gw): {single_gw} nodes - at risk if gateway drops") + # Name single-gateway infra nodes (these are critical risks) + single_gw_infra = [n for n in nodes_with_gw + if n.avg_gateways is not None and n.avg_gateways <= 1.0 and n.is_infrastructure] + if single_gw_infra: + lines.append(f" Single-gateway INFRASTRUCTURE (critical - if gateway drops, these go dark):") + for n in single_gw_infra: + name = _node_display_name(n.long_name, n.short_name, str(n.node_num)) + region = n.region or "Unlocated" + lines.append(f" {name} [{region}]") + if health.has_traceroute_data: lines.append("") lines.append(f"NETWORK TOPOLOGY: {health.traceroute_count} traceroutes, avg {health.avg_hop_count:.1f} hops, max {health.max_hop_count}") @@ -397,7 +420,7 @@ class MeshReporter: if pb["ok"]: parts.append(f"{pb['ok']} battery ok") if pb["low"]: parts.append(f"{pb['low']} battery low") if pb["critical"]: parts.append(f"{pb['critical']} battery critical") - lines.append(f"POWER: {', '.join(parts)}") + lines.append(f"POWER (infra): {', '.join(parts)}") lines.append("") lines.append(f"TOTAL: {health.total_nodes} nodes across {health.total_regions} regions.") @@ -420,6 +443,8 @@ class MeshReporter: critical = 0 for node in health.nodes.values(): + if not node.is_infrastructure: + continue # Only track power for infrastructure if node.battery_percent is None: continue if node.battery_percent > 100: diff --git a/meshai/router.py b/meshai/router.py index d6e0908..4cb251f 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -498,6 +498,31 @@ class MessageRouter: system_prompt = identity + static_prompt + # 2b. Dynamic command list (only shows enabled commands) + if self.dispatcher: + commands = self.dispatcher.get_commands() + if commands: + # Deduplicate aliases + seen_names = set() + unique_commands = [] + for cmd in commands: + name_lower = cmd.name.lower() + if name_lower not in seen_names: + seen_names.add(name_lower) + unique_commands.append(cmd) + + cmd_lines = [ + "\nYOUR COMMANDS (only mention these - do NOT mention any commands not listed here):" + ] + for cmd in sorted(unique_commands, key=lambda c: c.name): + cmd_lines.append(f" !{cmd.name} - {cmd.description}") + cmd_lines.append("") + cmd_lines.append( + "CRITICAL: ONLY mention commands in the list above when asked about commands. " + "If a command is not listed here, it does NOT exist. Do not invent commands." + ) + system_prompt += "\n".join(cmd_lines) + # 3. MeshMonitor info (only when enabled) if ( self.meshmonitor_sync @@ -509,7 +534,7 @@ class MessageRouter: "meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, " "traceroutes, security scanning, and auto-responder commands. Its trigger " "commands are listed below ??? if someone asks what commands are available, " - "mention both yours and MeshMonitor's. If someone asks where to get " + "ONLY list YOUR commands from YOUR COMMANDS above. If someone asks where to get " "MeshMonitor, direct them to github.com/Yeraze/meshmonitor" ) system_prompt += meshmonitor_intro