mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
feat: Phase 3 - LLM mesh health integration, recommendations, and health commands
New files: - mesh_reporter.py: MeshReporter class for prompt injection - build_tier1_summary(): ~500-800 token mesh health summary - build_region_detail(): Detailed region breakdown - build_node_detail(): Single node info with recommendations - build_recommendations(): Optimization suggestions - build_lora_compact(): Short format for LoRa messages - list_regions_compact(): Region list with scores - commands/health.py: !health and !region commands - !health: Quick mesh summary (no LLM) - !region [name]: Region info or list all regions Modified files: - router.py: Mesh question detection and prompt injection - _is_mesh_question(): Keyword/phrase matching - _detect_mesh_scope(): Node/region/mesh scope detection - Inject Tier 1/2 data for mesh questions - Add mesh awareness instructions to LLM - main.py: Create MeshReporter, pass to dispatcher/router - commands/dispatcher.py: Register health/region commands - mesh_health.py: Fix role type (int -> str) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
89315a8008
commit
44c74ccfd4
6 changed files with 1546 additions and 784 deletions
|
|
@ -157,6 +157,7 @@ def create_dispatcher(
|
||||||
prefix: str = "!",
|
prefix: str = "!",
|
||||||
disabled_commands: Optional[list[str]] = None,
|
disabled_commands: Optional[list[str]] = None,
|
||||||
custom_commands: Optional[dict] = None,
|
custom_commands: Optional[dict] = None,
|
||||||
|
mesh_reporter=None,
|
||||||
) -> CommandDispatcher:
|
) -> CommandDispatcher:
|
||||||
"""Create and populate command dispatcher with default commands.
|
"""Create and populate command dispatcher with default commands.
|
||||||
|
|
||||||
|
|
@ -164,6 +165,7 @@ def create_dispatcher(
|
||||||
prefix: Command prefix (default: "!")
|
prefix: Command prefix (default: "!")
|
||||||
disabled_commands: List of command names to disable
|
disabled_commands: List of command names to disable
|
||||||
custom_commands: Dict of name -> response for custom commands
|
custom_commands: Dict of name -> response for custom commands
|
||||||
|
mesh_reporter: MeshReporter instance for health commands
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured CommandDispatcher
|
Configured CommandDispatcher
|
||||||
|
|
@ -174,6 +176,7 @@ def create_dispatcher(
|
||||||
from .reset import ResetCommand
|
from .reset import ResetCommand
|
||||||
from .status import StatusCommand
|
from .status import StatusCommand
|
||||||
from .weather import WeatherCommand
|
from .weather import WeatherCommand
|
||||||
|
from .health import HealthCommand, RegionCommand
|
||||||
|
|
||||||
dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands)
|
dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands)
|
||||||
|
|
||||||
|
|
@ -185,6 +188,23 @@ def create_dispatcher(
|
||||||
dispatcher.register(StatusCommand())
|
dispatcher.register(StatusCommand())
|
||||||
dispatcher.register(WeatherCommand())
|
dispatcher.register(WeatherCommand())
|
||||||
|
|
||||||
|
# Register mesh health commands
|
||||||
|
health_cmd = HealthCommand(mesh_reporter)
|
||||||
|
dispatcher.register(health_cmd)
|
||||||
|
# Register aliases for health command
|
||||||
|
for alias in getattr(health_cmd, 'aliases', []):
|
||||||
|
alias_handler = HealthCommand(mesh_reporter)
|
||||||
|
alias_handler.name = alias
|
||||||
|
dispatcher.register(alias_handler)
|
||||||
|
|
||||||
|
region_cmd = RegionCommand(mesh_reporter)
|
||||||
|
dispatcher.register(region_cmd)
|
||||||
|
# Register aliases for region command
|
||||||
|
for alias in getattr(region_cmd, 'aliases', []):
|
||||||
|
alias_handler = RegionCommand(mesh_reporter)
|
||||||
|
alias_handler.name = alias
|
||||||
|
dispatcher.register(alias_handler)
|
||||||
|
|
||||||
# Register custom commands
|
# Register custom commands
|
||||||
if custom_commands:
|
if custom_commands:
|
||||||
for name, response in custom_commands.items():
|
for name, response in custom_commands.items():
|
||||||
|
|
|
||||||
58
meshai/commands/health.py
Normal file
58
meshai/commands/health.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Health and region commands for mesh status."""
|
||||||
|
|
||||||
|
from .base import CommandContext, CommandHandler
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCommand(CommandHandler):
|
||||||
|
"""Quick mesh health summary."""
|
||||||
|
|
||||||
|
name = "health"
|
||||||
|
description = "Show mesh health summary"
|
||||||
|
usage = "!health"
|
||||||
|
aliases = ["mesh", "status"]
|
||||||
|
|
||||||
|
def __init__(self, mesh_reporter=None):
|
||||||
|
"""Initialize with optional mesh reporter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mesh_reporter: MeshReporter instance for health data
|
||||||
|
"""
|
||||||
|
self._mesh_reporter = mesh_reporter
|
||||||
|
|
||||||
|
async def execute(self, args: str, context: CommandContext) -> str:
|
||||||
|
"""Return compact mesh health summary."""
|
||||||
|
if not self._mesh_reporter:
|
||||||
|
return "Mesh health not available."
|
||||||
|
|
||||||
|
return self._mesh_reporter.build_lora_compact("mesh")
|
||||||
|
|
||||||
|
|
||||||
|
class RegionCommand(CommandHandler):
|
||||||
|
"""Region health information."""
|
||||||
|
|
||||||
|
name = "region"
|
||||||
|
description = "Show region health info"
|
||||||
|
usage = "!region [name]"
|
||||||
|
aliases = ["reg"]
|
||||||
|
|
||||||
|
def __init__(self, mesh_reporter=None):
|
||||||
|
"""Initialize with optional mesh reporter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mesh_reporter: MeshReporter instance for health data
|
||||||
|
"""
|
||||||
|
self._mesh_reporter = mesh_reporter
|
||||||
|
|
||||||
|
async def execute(self, args: str, context: CommandContext) -> str:
|
||||||
|
"""Return region health info."""
|
||||||
|
if not self._mesh_reporter:
|
||||||
|
return "Mesh health not available."
|
||||||
|
|
||||||
|
args = args.strip()
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
# List all regions
|
||||||
|
return self._mesh_reporter.list_regions_compact()
|
||||||
|
|
||||||
|
# Get specific region detail (compact for LoRa)
|
||||||
|
return self._mesh_reporter.build_lora_compact("region", args)
|
||||||
889
meshai/main.py
889
meshai/main.py
|
|
@ -1,439 +1,450 @@
|
||||||
"""Main entry point for MeshAI."""
|
"""Main entry point for MeshAI."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .backends import AnthropicBackend, GoogleBackend, LLMBackend, OpenAIBackend
|
from .backends import AnthropicBackend, GoogleBackend, LLMBackend, OpenAIBackend
|
||||||
from .cli import run_configurator
|
from .cli import run_configurator
|
||||||
from .commands import CommandDispatcher
|
from .commands import CommandDispatcher
|
||||||
from .commands.dispatcher import create_dispatcher
|
from .commands.dispatcher import create_dispatcher
|
||||||
from .commands.status import set_start_time
|
from .commands.status import set_start_time
|
||||||
from .config import Config, load_config
|
from .config import Config, load_config
|
||||||
from .connector import MeshConnector, MeshMessage
|
from .connector import MeshConnector, MeshMessage
|
||||||
from .context import MeshContext
|
from .context import MeshContext
|
||||||
from .history import ConversationHistory
|
from .history import ConversationHistory
|
||||||
from .memory import ConversationSummary
|
from .memory import ConversationSummary
|
||||||
from .responder import Responder
|
from .responder import Responder
|
||||||
from .router import MessageRouter, RouteType
|
from .router import MessageRouter, RouteType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MeshAI:
|
class MeshAI:
|
||||||
"""Main application class."""
|
"""Main application class."""
|
||||||
|
|
||||||
def __init__(self, config: Config):
|
def __init__(self, config: Config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.connector: Optional[MeshConnector] = None
|
self.connector: Optional[MeshConnector] = None
|
||||||
self.history: Optional[ConversationHistory] = None
|
self.history: Optional[ConversationHistory] = None
|
||||||
self.dispatcher: Optional[CommandDispatcher] = None
|
self.dispatcher: Optional[CommandDispatcher] = None
|
||||||
self.llm: Optional[LLMBackend] = None
|
self.llm: Optional[LLMBackend] = None
|
||||||
self.context: Optional[MeshContext] = None
|
self.context: Optional[MeshContext] = None
|
||||||
self.meshmonitor_sync = None
|
self.meshmonitor_sync = None
|
||||||
self.knowledge = None
|
self.knowledge = None
|
||||||
self.source_manager = None
|
self.source_manager = None
|
||||||
self.health_engine = None
|
self.health_engine = None
|
||||||
self.router: Optional[MessageRouter] = None
|
self.mesh_reporter = None
|
||||||
self.responder: Optional[Responder] = None
|
self.router: Optional[MessageRouter] = None
|
||||||
self._running = False
|
self.responder: Optional[Responder] = None
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._running = False
|
||||||
self._last_cleanup: float = 0.0
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._last_health_compute: float = 0.0
|
self._last_cleanup: float = 0.0
|
||||||
|
self._last_health_compute: float = 0.0
|
||||||
async def start(self) -> None:
|
|
||||||
"""Start the bot."""
|
async def start(self) -> None:
|
||||||
logger.info(f"Starting MeshAI v{__version__}")
|
"""Start the bot."""
|
||||||
set_start_time(time.time())
|
logger.info(f"Starting MeshAI v{__version__}")
|
||||||
|
set_start_time(time.time())
|
||||||
# Initialize components
|
|
||||||
await self._init_components()
|
# Initialize components
|
||||||
|
await self._init_components()
|
||||||
# Connect to Meshtastic
|
|
||||||
self.connector.connect()
|
# Connect to Meshtastic
|
||||||
self.connector.set_message_callback(self._on_message, asyncio.get_event_loop())
|
self.connector.connect()
|
||||||
|
self.connector.set_message_callback(self._on_message, asyncio.get_event_loop())
|
||||||
# Add own node ID to context ignore list
|
|
||||||
if self.context and self.connector.my_node_id:
|
# Add own node ID to context ignore list
|
||||||
self.context._ignore_nodes.add(self.connector.my_node_id)
|
if self.context and self.connector.my_node_id:
|
||||||
|
self.context._ignore_nodes.add(self.connector.my_node_id)
|
||||||
self._running = True
|
|
||||||
self._loop = asyncio.get_event_loop()
|
self._running = True
|
||||||
self._last_cleanup = time.time()
|
self._loop = asyncio.get_event_loop()
|
||||||
self._last_health_compute = 0.0
|
self._last_cleanup = time.time()
|
||||||
|
self._last_health_compute = 0.0
|
||||||
# Write PID file
|
|
||||||
self._write_pid()
|
# Write PID file
|
||||||
|
self._write_pid()
|
||||||
logger.info("MeshAI started successfully")
|
|
||||||
|
logger.info("MeshAI started successfully")
|
||||||
# Keep running
|
|
||||||
while self._running:
|
# Keep running
|
||||||
await asyncio.sleep(1)
|
while self._running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
# Periodic MeshMonitor refresh
|
|
||||||
if self.meshmonitor_sync:
|
# Periodic MeshMonitor refresh
|
||||||
self.meshmonitor_sync.maybe_refresh()
|
if self.meshmonitor_sync:
|
||||||
|
self.meshmonitor_sync.maybe_refresh()
|
||||||
# Periodic mesh source refresh and health computation
|
|
||||||
if self.source_manager:
|
# Periodic mesh source refresh and health computation
|
||||||
refreshed = self.source_manager.refresh_all()
|
if self.source_manager:
|
||||||
# Recompute health after source refresh
|
refreshed = self.source_manager.refresh_all()
|
||||||
if refreshed > 0 and self.health_engine:
|
# Recompute health after source refresh
|
||||||
self.health_engine.compute(self.source_manager)
|
if refreshed > 0 and self.health_engine:
|
||||||
self._last_health_compute = time.time()
|
self.health_engine.compute(self.source_manager)
|
||||||
|
self._last_health_compute = time.time()
|
||||||
# Periodic cleanup
|
|
||||||
if time.time() - self._last_cleanup >= 3600:
|
# Periodic cleanup
|
||||||
await self.history.cleanup_expired()
|
if time.time() - self._last_cleanup >= 3600:
|
||||||
if self.context:
|
await self.history.cleanup_expired()
|
||||||
self.context.prune()
|
if self.context:
|
||||||
self._last_cleanup = time.time()
|
self.context.prune()
|
||||||
|
self._last_cleanup = time.time()
|
||||||
async def stop(self) -> None:
|
|
||||||
"""Stop the bot."""
|
async def stop(self) -> None:
|
||||||
logger.info("Stopping MeshAI...")
|
"""Stop the bot."""
|
||||||
self._running = False
|
logger.info("Stopping MeshAI...")
|
||||||
|
self._running = False
|
||||||
if self.connector:
|
|
||||||
self.connector.disconnect()
|
if self.connector:
|
||||||
|
self.connector.disconnect()
|
||||||
if self.history:
|
|
||||||
await self.history.close()
|
if self.history:
|
||||||
|
await self.history.close()
|
||||||
if self.llm:
|
|
||||||
await self.llm.close()
|
if self.llm:
|
||||||
if self.knowledge:
|
await self.llm.close()
|
||||||
self.knowledge.close()
|
if self.knowledge:
|
||||||
|
self.knowledge.close()
|
||||||
self._remove_pid()
|
|
||||||
logger.info("MeshAI stopped")
|
self._remove_pid()
|
||||||
|
logger.info("MeshAI stopped")
|
||||||
async def _init_components(self) -> None:
|
|
||||||
"""Initialize all components."""
|
async def _init_components(self) -> None:
|
||||||
# Conversation history
|
"""Initialize all components."""
|
||||||
self.history = ConversationHistory(self.config.history)
|
# Conversation history
|
||||||
await self.history.initialize()
|
self.history = ConversationHistory(self.config.history)
|
||||||
|
await self.history.initialize()
|
||||||
# Command dispatcher
|
|
||||||
self.dispatcher = create_dispatcher(
|
# LLM backend
|
||||||
prefix=self.config.commands.prefix,
|
api_key = self.config.resolve_api_key()
|
||||||
disabled_commands=self.config.commands.disabled_commands,
|
if not api_key:
|
||||||
custom_commands=self.config.commands.custom_commands,
|
logger.warning("No API key configured - LLM responses will fail")
|
||||||
)
|
|
||||||
|
# Memory config
|
||||||
# LLM backend
|
mem_cfg = self.config.memory
|
||||||
api_key = self.config.resolve_api_key()
|
window_size = mem_cfg.window_size if mem_cfg.enabled else 0
|
||||||
if not api_key:
|
summarize_threshold = mem_cfg.summarize_threshold
|
||||||
logger.warning("No API key configured - LLM responses will fail")
|
|
||||||
|
# Create backend
|
||||||
# Memory config
|
backend = self.config.llm.backend.lower()
|
||||||
mem_cfg = self.config.memory
|
if backend == "openai":
|
||||||
window_size = mem_cfg.window_size if mem_cfg.enabled else 0
|
self.llm = OpenAIBackend(
|
||||||
summarize_threshold = mem_cfg.summarize_threshold
|
self.config.llm, api_key, window_size, summarize_threshold
|
||||||
|
)
|
||||||
# Create backend
|
elif backend == "anthropic":
|
||||||
backend = self.config.llm.backend.lower()
|
self.llm = AnthropicBackend(
|
||||||
if backend == "openai":
|
self.config.llm, api_key, window_size, summarize_threshold
|
||||||
self.llm = OpenAIBackend(
|
)
|
||||||
self.config.llm, api_key, window_size, summarize_threshold
|
elif backend == "google":
|
||||||
)
|
self.llm = GoogleBackend(
|
||||||
elif backend == "anthropic":
|
self.config.llm, api_key, window_size, summarize_threshold
|
||||||
self.llm = AnthropicBackend(
|
)
|
||||||
self.config.llm, api_key, window_size, summarize_threshold
|
else:
|
||||||
)
|
logger.warning(f"Unknown backend '{backend}', defaulting to OpenAI")
|
||||||
elif backend == "google":
|
self.llm = OpenAIBackend(
|
||||||
self.llm = GoogleBackend(
|
self.config.llm, api_key, window_size, summarize_threshold
|
||||||
self.config.llm, api_key, window_size, summarize_threshold
|
)
|
||||||
)
|
|
||||||
else:
|
# Load persisted summaries into memory cache
|
||||||
logger.warning(f"Unknown backend '{backend}', defaulting to OpenAI")
|
await self._load_summaries()
|
||||||
self.llm = OpenAIBackend(
|
|
||||||
self.config.llm, api_key, window_size, summarize_threshold
|
# Meshtastic connector
|
||||||
)
|
self.connector = MeshConnector(self.config.connection)
|
||||||
|
|
||||||
# Load persisted summaries into memory cache
|
# Passive mesh context buffer
|
||||||
await self._load_summaries()
|
ctx_cfg = self.config.context
|
||||||
|
if ctx_cfg.enabled:
|
||||||
# Meshtastic connector
|
self.context = MeshContext(
|
||||||
self.connector = MeshConnector(self.config.connection)
|
observe_channels=ctx_cfg.observe_channels or None,
|
||||||
|
ignore_nodes=ctx_cfg.ignore_nodes or None,
|
||||||
# Passive mesh context buffer
|
max_age=ctx_cfg.max_age,
|
||||||
ctx_cfg = self.config.context
|
)
|
||||||
if ctx_cfg.enabled:
|
logger.info("Mesh context buffer enabled")
|
||||||
self.context = MeshContext(
|
else:
|
||||||
observe_channels=ctx_cfg.observe_channels or None,
|
self.context = None
|
||||||
ignore_nodes=ctx_cfg.ignore_nodes or None,
|
|
||||||
max_age=ctx_cfg.max_age,
|
# MeshMonitor trigger sync
|
||||||
)
|
mm_cfg = self.config.meshmonitor
|
||||||
logger.info("Mesh context buffer enabled")
|
if mm_cfg.enabled and mm_cfg.url:
|
||||||
else:
|
from .meshmonitor import MeshMonitorSync
|
||||||
self.context = None
|
self.meshmonitor_sync = MeshMonitorSync(
|
||||||
|
url=mm_cfg.url,
|
||||||
# MeshMonitor trigger sync
|
refresh_interval=mm_cfg.refresh_interval,
|
||||||
mm_cfg = self.config.meshmonitor
|
)
|
||||||
if mm_cfg.enabled and mm_cfg.url:
|
count = self.meshmonitor_sync.load()
|
||||||
from .meshmonitor import MeshMonitorSync
|
logger.info(f"MeshMonitor sync enabled, loaded {count} triggers")
|
||||||
self.meshmonitor_sync = MeshMonitorSync(
|
else:
|
||||||
url=mm_cfg.url,
|
self.meshmonitor_sync = None
|
||||||
refresh_interval=mm_cfg.refresh_interval,
|
|
||||||
)
|
# Mesh data sources
|
||||||
count = self.meshmonitor_sync.load()
|
enabled_sources = [s for s in self.config.mesh_sources if s.enabled]
|
||||||
logger.info(f"MeshMonitor sync enabled, loaded {count} triggers")
|
if enabled_sources:
|
||||||
else:
|
from .mesh_sources import MeshSourceManager
|
||||||
self.meshmonitor_sync = None
|
self.source_manager = MeshSourceManager(enabled_sources)
|
||||||
|
# Initial fetch
|
||||||
# Mesh data sources
|
self.source_manager.refresh_all()
|
||||||
enabled_sources = [s for s in self.config.mesh_sources if s.enabled]
|
# Log status
|
||||||
if enabled_sources:
|
for status in self.source_manager.get_status():
|
||||||
from .mesh_sources import MeshSourceManager
|
if status["is_loaded"]:
|
||||||
self.source_manager = MeshSourceManager(enabled_sources)
|
logger.info(
|
||||||
# Initial fetch
|
f"Mesh source '{status['name']}' ({status['type']}): "
|
||||||
self.source_manager.refresh_all()
|
f"{status['node_count']} nodes"
|
||||||
# Log status
|
)
|
||||||
for status in self.source_manager.get_status():
|
else:
|
||||||
if status["is_loaded"]:
|
logger.warning(
|
||||||
logger.info(
|
f"Mesh source '{status['name']}' ({status['type']}): "
|
||||||
f"Mesh source '{status['name']}' ({status['type']}): "
|
f"failed - {status.get('last_error', 'unknown error')}"
|
||||||
f"{status['node_count']} nodes"
|
)
|
||||||
)
|
else:
|
||||||
else:
|
self.source_manager = None
|
||||||
logger.warning(
|
|
||||||
f"Mesh source '{status['name']}' ({status['type']}): "
|
# Mesh health engine
|
||||||
f"failed - {status.get('last_error', 'unknown error')}"
|
mi_cfg = self.config.mesh_intelligence
|
||||||
)
|
if mi_cfg.enabled and self.source_manager:
|
||||||
else:
|
from .mesh_health import MeshHealthEngine
|
||||||
self.source_manager = None
|
self.health_engine = MeshHealthEngine(
|
||||||
|
regions=mi_cfg.regions,
|
||||||
# Mesh health engine
|
locality_radius=mi_cfg.locality_radius_miles,
|
||||||
mi_cfg = self.config.mesh_intelligence
|
offline_threshold_hours=mi_cfg.offline_threshold_hours,
|
||||||
if mi_cfg.enabled and self.source_manager:
|
packet_threshold=mi_cfg.packet_threshold,
|
||||||
from .mesh_health import MeshHealthEngine
|
battery_warning_percent=mi_cfg.battery_warning_percent,
|
||||||
self.health_engine = MeshHealthEngine(
|
)
|
||||||
regions=mi_cfg.regions,
|
# Initial health computation
|
||||||
locality_radius=mi_cfg.locality_radius_miles,
|
mesh_health = self.health_engine.compute(self.source_manager)
|
||||||
offline_threshold_hours=mi_cfg.offline_threshold_hours,
|
self._last_health_compute = time.time()
|
||||||
packet_threshold=mi_cfg.packet_threshold,
|
logger.info(
|
||||||
battery_warning_percent=mi_cfg.battery_warning_percent,
|
f"Mesh intelligence enabled: {mesh_health.total_nodes} nodes, "
|
||||||
)
|
f"{mesh_health.total_regions} regions, "
|
||||||
# Initial health computation
|
f"score {mesh_health.score.composite:.0f}/100 ({mesh_health.score.tier})"
|
||||||
mesh_health = self.health_engine.compute(self.source_manager)
|
)
|
||||||
self._last_health_compute = time.time()
|
else:
|
||||||
logger.info(
|
self.health_engine = None
|
||||||
f"Mesh intelligence enabled: {mesh_health.total_nodes} nodes, "
|
|
||||||
f"{mesh_health.total_regions} regions, "
|
# Mesh reporter (for LLM prompt injection and commands)
|
||||||
f"score {mesh_health.score.composite:.0f}/100 ({mesh_health.score.tier})"
|
if self.health_engine and self.source_manager:
|
||||||
)
|
from .mesh_reporter import MeshReporter
|
||||||
else:
|
self.mesh_reporter = MeshReporter(self.health_engine, self.source_manager)
|
||||||
self.health_engine = None
|
logger.info("Mesh reporter enabled")
|
||||||
|
else:
|
||||||
# Knowledge base
|
self.mesh_reporter = None
|
||||||
kb_cfg = self.config.knowledge
|
|
||||||
if kb_cfg.enabled and kb_cfg.db_path:
|
# Knowledge base
|
||||||
from .knowledge import KnowledgeSearch
|
kb_cfg = self.config.knowledge
|
||||||
self.knowledge = KnowledgeSearch(
|
if kb_cfg.enabled and kb_cfg.db_path:
|
||||||
db_path=kb_cfg.db_path,
|
from .knowledge import KnowledgeSearch
|
||||||
top_k=kb_cfg.top_k,
|
self.knowledge = KnowledgeSearch(
|
||||||
)
|
db_path=kb_cfg.db_path,
|
||||||
else:
|
top_k=kb_cfg.top_k,
|
||||||
self.knowledge = None
|
)
|
||||||
|
else:
|
||||||
# Message router
|
self.knowledge = None
|
||||||
self.router = MessageRouter(
|
|
||||||
self.config, self.connector, self.history, self.dispatcher, self.llm,
|
# Command dispatcher (needs mesh_reporter for health commands)
|
||||||
context=self.context,
|
self.dispatcher = create_dispatcher(
|
||||||
meshmonitor_sync=self.meshmonitor_sync,
|
prefix=self.config.commands.prefix,
|
||||||
knowledge=self.knowledge,
|
disabled_commands=self.config.commands.disabled_commands,
|
||||||
source_manager=self.source_manager,
|
custom_commands=self.config.commands.custom_commands,
|
||||||
health_engine=self.health_engine,
|
mesh_reporter=self.mesh_reporter,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Responder
|
# Message router
|
||||||
self.responder = Responder(self.config.response, self.connector)
|
self.router = MessageRouter(
|
||||||
|
self.config, self.connector, self.history, self.dispatcher, self.llm,
|
||||||
async def _on_message(self, message: MeshMessage) -> None:
|
context=self.context,
|
||||||
"""Handle incoming message."""
|
meshmonitor_sync=self.meshmonitor_sync,
|
||||||
try:
|
knowledge=self.knowledge,
|
||||||
# Passively observe channel broadcasts for context (before filtering)
|
source_manager=self.source_manager,
|
||||||
if self.context and not message.is_dm and message.text:
|
health_engine=self.health_engine,
|
||||||
self.context.observe(
|
mesh_reporter=self.mesh_reporter,
|
||||||
sender_name=message.sender_name,
|
)
|
||||||
sender_id=message.sender_id,
|
|
||||||
text=message.text,
|
# Responder
|
||||||
channel=message.channel,
|
self.responder = Responder(self.config.response, self.connector)
|
||||||
is_dm=False,
|
|
||||||
)
|
async def _on_message(self, message: MeshMessage) -> None:
|
||||||
|
"""Handle incoming message."""
|
||||||
# Check if we should respond
|
try:
|
||||||
if not self.router.should_respond(message):
|
# Passively observe channel broadcasts for context (before filtering)
|
||||||
return
|
if self.context and not message.is_dm and message.text:
|
||||||
|
self.context.observe(
|
||||||
logger.info(
|
sender_name=message.sender_name,
|
||||||
f"Processing message from {message.sender_name} ({message.sender_id}): "
|
sender_id=message.sender_id,
|
||||||
f"{message.text[:50]}..."
|
text=message.text,
|
||||||
)
|
channel=message.channel,
|
||||||
|
is_dm=False,
|
||||||
# Route the message
|
)
|
||||||
# Check for continuation request first
|
|
||||||
continuation_messages = self.router.check_continuation(message)
|
# Check if we should respond
|
||||||
if continuation_messages:
|
if not self.router.should_respond(message):
|
||||||
await self.responder.send_response(
|
return
|
||||||
continuation_messages,
|
|
||||||
destination=message.sender_id,
|
logger.info(
|
||||||
channel=message.channel,
|
f"Processing message from {message.sender_name} ({message.sender_id}): "
|
||||||
)
|
f"{message.text[:50]}..."
|
||||||
return
|
)
|
||||||
|
|
||||||
result = await self.router.route(message)
|
# Route the message
|
||||||
|
# Check for continuation request first
|
||||||
if result.route_type == RouteType.IGNORE:
|
continuation_messages = self.router.check_continuation(message)
|
||||||
return
|
if continuation_messages:
|
||||||
|
await self.responder.send_response(
|
||||||
# Determine response
|
continuation_messages,
|
||||||
if result.route_type == RouteType.COMMAND:
|
destination=message.sender_id,
|
||||||
messages = result.response # Commands return single string
|
channel=message.channel,
|
||||||
elif result.route_type == RouteType.LLM:
|
)
|
||||||
messages = await self.router.generate_llm_response(message, result.query)
|
return
|
||||||
else:
|
|
||||||
return
|
result = await self.router.route(message)
|
||||||
|
|
||||||
if not messages:
|
if result.route_type == RouteType.IGNORE:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send DM response
|
# Determine response
|
||||||
await self.responder.send_response(
|
if result.route_type == RouteType.COMMAND:
|
||||||
messages,
|
messages = result.response # Commands return single string
|
||||||
destination=message.sender_id,
|
elif result.route_type == RouteType.LLM:
|
||||||
channel=message.channel,
|
messages = await self.router.generate_llm_response(message, result.query)
|
||||||
)
|
else:
|
||||||
|
return
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error handling message: {e}", exc_info=True)
|
if not messages:
|
||||||
|
return
|
||||||
async def _load_summaries(self) -> None:
|
|
||||||
"""Load persisted summaries from database into memory cache."""
|
# Send DM response
|
||||||
memory = self.llm.get_memory()
|
await self.responder.send_response(
|
||||||
if not memory:
|
messages,
|
||||||
return
|
destination=message.sender_id,
|
||||||
|
channel=message.channel,
|
||||||
if not self.history or not self.history._db:
|
)
|
||||||
return
|
|
||||||
|
except Exception as e:
|
||||||
try:
|
logger.error(f"Error handling message: {e}", exc_info=True)
|
||||||
async with self.history._lock:
|
|
||||||
cursor = await self.history._db.execute(
|
async def _load_summaries(self) -> None:
|
||||||
"SELECT user_id, summary, message_count, updated_at "
|
"""Load persisted summaries from database into memory cache."""
|
||||||
"FROM conversation_summaries"
|
memory = self.llm.get_memory()
|
||||||
)
|
if not memory:
|
||||||
rows = await cursor.fetchall()
|
return
|
||||||
|
|
||||||
loaded = 0
|
if not self.history or not self.history._db:
|
||||||
for row in rows:
|
return
|
||||||
user_id, summary_text, message_count, updated_at = row
|
|
||||||
summary = ConversationSummary(
|
try:
|
||||||
summary=summary_text,
|
async with self.history._lock:
|
||||||
last_updated=updated_at,
|
cursor = await self.history._db.execute(
|
||||||
message_count=message_count,
|
"SELECT user_id, summary, message_count, updated_at "
|
||||||
)
|
"FROM conversation_summaries"
|
||||||
memory.load_summary(user_id, summary)
|
)
|
||||||
loaded += 1
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
if loaded:
|
loaded = 0
|
||||||
logger.info(f"Loaded {loaded} conversation summaries from database")
|
for row in rows:
|
||||||
|
user_id, summary_text, message_count, updated_at = row
|
||||||
except Exception as e:
|
summary = ConversationSummary(
|
||||||
logger.warning(f"Failed to load summaries from database: {e}")
|
summary=summary_text,
|
||||||
|
last_updated=updated_at,
|
||||||
def _write_pid(self) -> None:
|
message_count=message_count,
|
||||||
"""Write PID file."""
|
)
|
||||||
pid_file = Path("/tmp/meshai.pid")
|
memory.load_summary(user_id, summary)
|
||||||
pid_file.write_text(str(os.getpid()))
|
loaded += 1
|
||||||
|
|
||||||
def _remove_pid(self) -> None:
|
if loaded:
|
||||||
"""Remove PID file."""
|
logger.info(f"Loaded {loaded} conversation summaries from database")
|
||||||
pid_file = Path("/tmp/meshai.pid")
|
|
||||||
if pid_file.exists():
|
except Exception as e:
|
||||||
pid_file.unlink()
|
logger.warning(f"Failed to load summaries from database: {e}")
|
||||||
|
|
||||||
|
def _write_pid(self) -> None:
|
||||||
def setup_logging(verbose: bool = False) -> None:
|
"""Write PID file."""
|
||||||
"""Configure logging."""
|
pid_file = Path("/tmp/meshai.pid")
|
||||||
level = logging.DEBUG if verbose else logging.INFO
|
pid_file.write_text(str(os.getpid()))
|
||||||
logging.basicConfig(
|
|
||||||
level=level,
|
def _remove_pid(self) -> None:
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
"""Remove PID file."""
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
pid_file = Path("/tmp/meshai.pid")
|
||||||
)
|
if pid_file.exists():
|
||||||
|
pid_file.unlink()
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Main entry point."""
|
def setup_logging(verbose: bool = False) -> None:
|
||||||
parser = argparse.ArgumentParser(
|
"""Configure logging."""
|
||||||
description="MeshAI - LLM-powered Meshtastic assistant",
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
prog="meshai",
|
logging.basicConfig(
|
||||||
)
|
level=level,
|
||||||
parser.add_argument(
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
"--version", "-V", action="version", version=f"%(prog)s {__version__}"
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--config", "-c", action="store_true", help="Launch configuration tool"
|
|
||||||
)
|
def main() -> None:
|
||||||
parser.add_argument(
|
"""Main entry point."""
|
||||||
"--config-file",
|
parser = argparse.ArgumentParser(
|
||||||
"-f",
|
description="MeshAI - LLM-powered Meshtastic assistant",
|
||||||
type=Path,
|
prog="meshai",
|
||||||
default=Path("config.yaml"),
|
)
|
||||||
help="Path to config file (default: config.yaml)",
|
parser.add_argument(
|
||||||
)
|
"--version", "-V", action="version", version=f"%(prog)s {__version__}"
|
||||||
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
|
)
|
||||||
|
parser.add_argument(
|
||||||
args = parser.parse_args()
|
"--config", "-c", action="store_true", help="Launch configuration tool"
|
||||||
|
)
|
||||||
setup_logging(args.verbose)
|
parser.add_argument(
|
||||||
|
"--config-file",
|
||||||
# Launch configurator if requested
|
"-f",
|
||||||
if args.config:
|
type=Path,
|
||||||
run_configurator(args.config_file)
|
default=Path("config.yaml"),
|
||||||
return
|
help="Path to config file (default: config.yaml)",
|
||||||
|
)
|
||||||
# Load config
|
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
|
||||||
config = load_config(args.config_file)
|
|
||||||
|
args = parser.parse_args()
|
||||||
# Check if config exists
|
|
||||||
if not args.config_file.exists():
|
setup_logging(args.verbose)
|
||||||
logger.warning(f"Config file not found: {args.config_file}")
|
|
||||||
logger.info("Run 'meshai --config' to create one, or copy config.example.yaml")
|
# Launch configurator if requested
|
||||||
sys.exit(1)
|
if args.config:
|
||||||
|
run_configurator(args.config_file)
|
||||||
# Create and run bot
|
return
|
||||||
bot = MeshAI(config)
|
|
||||||
|
# Load config
|
||||||
# Handle signals
|
config = load_config(args.config_file)
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
# Check if config exists
|
||||||
|
if not args.config_file.exists():
|
||||||
def signal_handler(sig, frame):
|
logger.warning(f"Config file not found: {args.config_file}")
|
||||||
logger.info(f"Received signal {sig}")
|
logger.info("Run 'meshai --config' to create one, or copy config.example.yaml")
|
||||||
loop.create_task(bot.stop())
|
sys.exit(1)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
# Create and run bot
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
bot = MeshAI(config)
|
||||||
|
|
||||||
try:
|
# Handle signals
|
||||||
loop.run_until_complete(bot.start())
|
loop = asyncio.new_event_loop()
|
||||||
except KeyboardInterrupt:
|
asyncio.set_event_loop(loop)
|
||||||
pass
|
|
||||||
finally:
|
def signal_handler(sig, frame):
|
||||||
loop.run_until_complete(bot.stop())
|
logger.info(f"Received signal {sig}")
|
||||||
loop.close()
|
loop.create_task(bot.stop())
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
if __name__ == "__main__":
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
main()
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(bot.start())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
loop.run_until_complete(bot.stop())
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,7 @@ class MeshHealthEngine:
|
||||||
role = node.get("role") or node.get("hwModel") or ""
|
role = node.get("role") or node.get("hwModel") or ""
|
||||||
|
|
||||||
# Determine if infrastructure
|
# Determine if infrastructure
|
||||||
is_infra = role.upper() in INFRASTRUCTURE_ROLES
|
is_infra = str(role).upper() in INFRASTRUCTURE_ROLES
|
||||||
|
|
||||||
# Get position (handle different API formats)
|
# Get position (handle different API formats)
|
||||||
lat = node.get("latitude") or node.get("lat")
|
lat = node.get("latitude") or node.get("lat")
|
||||||
|
|
|
||||||
545
meshai/mesh_reporter.py
Normal file
545
meshai/mesh_reporter.py
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
"""Mesh health reporting for LLM prompt injection and commands."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_age(timestamp: float) -> str:
|
||||||
|
"""Format a timestamp as human-readable age."""
|
||||||
|
if not timestamp:
|
||||||
|
return "never"
|
||||||
|
|
||||||
|
age_seconds = time.time() - timestamp
|
||||||
|
if age_seconds < 0:
|
||||||
|
return "just now"
|
||||||
|
elif age_seconds < 60:
|
||||||
|
return f"{int(age_seconds)}s ago"
|
||||||
|
elif age_seconds < 3600:
|
||||||
|
return f"{int(age_seconds / 60)}m ago"
|
||||||
|
elif age_seconds < 86400:
|
||||||
|
return f"{int(age_seconds / 3600)}h ago"
|
||||||
|
else:
|
||||||
|
return f"{int(age_seconds / 86400)}d ago"
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_flag(tier: str) -> str:
|
||||||
|
"""Get warning flag for health tier."""
|
||||||
|
if tier == "Critical":
|
||||||
|
return " !!"
|
||||||
|
elif tier == "Warning":
|
||||||
|
return " !"
|
||||||
|
elif tier == "Unhealthy":
|
||||||
|
return " !"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class MeshReporter:
|
||||||
|
"""Builds text blocks for mesh health prompt injection."""
|
||||||
|
|
||||||
|
def __init__(self, health_engine, source_manager):
|
||||||
|
"""Initialize reporter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
health_engine: MeshHealthEngine instance
|
||||||
|
source_manager: MeshSourceManager instance
|
||||||
|
"""
|
||||||
|
self.health_engine = health_engine
|
||||||
|
self.source_manager = source_manager
|
||||||
|
|
||||||
|
def build_tier1_summary(self) -> str:
|
||||||
|
"""Build compact mesh summary for LLM injection (~500-800 tokens).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted summary string
|
||||||
|
"""
|
||||||
|
health = self.health_engine.mesh_health
|
||||||
|
if not health:
|
||||||
|
return "LIVE MESH HEALTH DATA: No data available yet."
|
||||||
|
|
||||||
|
score = health.score
|
||||||
|
ts = datetime.fromtimestamp(health.last_computed).strftime("%H:%M %Z")
|
||||||
|
|
||||||
|
# Infrastructure stats
|
||||||
|
infra_online = score.infra_online
|
||||||
|
infra_total = score.infra_total
|
||||||
|
infra_pct = int((infra_online / infra_total * 100) if infra_total > 0 else 100)
|
||||||
|
|
||||||
|
# Utilization
|
||||||
|
util = score.util_percent
|
||||||
|
if util < 15:
|
||||||
|
util_label = "Low"
|
||||||
|
elif util < 20:
|
||||||
|
util_label = "Moderate"
|
||||||
|
elif util < 25:
|
||||||
|
util_label = "Elevated"
|
||||||
|
else:
|
||||||
|
util_label = "High"
|
||||||
|
|
||||||
|
# Power
|
||||||
|
if score.battery_warnings == 0:
|
||||||
|
power_label = "Good"
|
||||||
|
elif score.battery_warnings <= 2:
|
||||||
|
power_label = "Some low batteries"
|
||||||
|
else:
|
||||||
|
power_label = f"{score.battery_warnings} low batteries"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"LIVE MESH HEALTH DATA (as of {ts}):",
|
||||||
|
"",
|
||||||
|
f"Overall: {score.composite:.0f}/100 ({score.tier})",
|
||||||
|
f"Infrastructure: {infra_online}/{infra_total} online ({infra_pct}%)",
|
||||||
|
f"Channel Utilization: {util:.1f}% avg ({util_label})",
|
||||||
|
f"Node Behavior: {score.flagged_nodes} nodes flagged",
|
||||||
|
f"Power/Solar: {power_label} ({score.solar_index:.0f}% solar index)",
|
||||||
|
"",
|
||||||
|
"Regions:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Region summaries
|
||||||
|
for region in health.regions:
|
||||||
|
rs = region.score
|
||||||
|
flag = _tier_flag(rs.tier)
|
||||||
|
infra_str = f"{rs.infra_online}/{rs.infra_total} infra"
|
||||||
|
lines.append(f" {region.name}: {rs.composite:.0f}/100 - {infra_str}, {rs.util_percent:.0f}% util{flag}")
|
||||||
|
|
||||||
|
# Top issues
|
||||||
|
issues = self._gather_top_issues(health)
|
||||||
|
if issues:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Top Issues:")
|
||||||
|
for i, issue in enumerate(issues[:5], 1):
|
||||||
|
lines.append(f" {i}. {issue}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"{health.total_nodes} nodes across {health.total_regions} regions. User can ask about any region, locality, or node for details.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _gather_top_issues(self, health) -> list[str]:
|
||||||
|
"""Gather top issues across all pillars."""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Infrastructure issues (offline nodes)
|
||||||
|
for region in health.regions:
|
||||||
|
offline_infra = []
|
||||||
|
for nid in region.node_ids:
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if node and node.is_infrastructure and not node.is_online:
|
||||||
|
offline_infra.append(node.short_name or nid[:4])
|
||||||
|
if offline_infra:
|
||||||
|
total_infra = sum(1 for nid in region.node_ids
|
||||||
|
if health.nodes.get(nid) and health.nodes[nid].is_infrastructure)
|
||||||
|
online = total_infra - len(offline_infra)
|
||||||
|
issues.append(f"{region.name}: {online}/{total_infra} infrastructure nodes offline ({', '.join(offline_infra[:3])})")
|
||||||
|
|
||||||
|
# Utilization issues
|
||||||
|
for region in health.regions:
|
||||||
|
if region.score.util_percent >= 25:
|
||||||
|
issues.append(f"{region.name}: channel utilization at {region.score.util_percent:.0f}% (Warning)")
|
||||||
|
elif region.score.util_percent >= 20:
|
||||||
|
issues.append(f"{region.name}: channel utilization at {region.score.util_percent:.0f}% (Elevated)")
|
||||||
|
|
||||||
|
# Behavior issues (high packet nodes)
|
||||||
|
flagged = self.health_engine.get_flagged_nodes()
|
||||||
|
for node in flagged[:3]:
|
||||||
|
threshold = self.health_engine.packet_threshold
|
||||||
|
ratio = node.non_text_packets / threshold
|
||||||
|
issues.append(f"Node {node.short_name or node.node_id[:4]} sending {node.non_text_packets} non-text packets/24h ({ratio:.1f}x threshold)")
|
||||||
|
|
||||||
|
# Battery issues
|
||||||
|
battery_warnings = self.health_engine.get_battery_warnings()
|
||||||
|
for node in battery_warnings[:2]:
|
||||||
|
issues.append(f"Node {node.short_name or node.node_id[:4]} battery at {node.battery_percent:.0f}%")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def build_region_detail(self, region_name: str) -> str:
|
||||||
|
"""Build detailed breakdown for a specific region.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
region_name: Region to get detail for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted region detail string
|
||||||
|
"""
|
||||||
|
health = self.health_engine.mesh_health
|
||||||
|
if not health:
|
||||||
|
return f"REGION DETAIL: {region_name}\nNo data available."
|
||||||
|
|
||||||
|
# Find region (fuzzy match)
|
||||||
|
region = self._find_region(region_name)
|
||||||
|
if not region:
|
||||||
|
return f"REGION DETAIL: {region_name}\nRegion not found."
|
||||||
|
|
||||||
|
rs = region.score
|
||||||
|
lines = [
|
||||||
|
f"REGION DETAIL: {region.name}",
|
||||||
|
f"Score: {rs.composite:.0f}/100 ({rs.tier})",
|
||||||
|
"",
|
||||||
|
f"Infrastructure ({rs.infra_online}/{rs.infra_total}):",
|
||||||
|
]
|
||||||
|
|
||||||
|
# List infrastructure nodes
|
||||||
|
for nid in region.node_ids:
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if not node or not node.is_infrastructure:
|
||||||
|
continue
|
||||||
|
status = "+" if node.is_online else "X"
|
||||||
|
age = _format_age(node.last_seen)
|
||||||
|
bat = f", bat {node.battery_percent:.0f}%" if node.battery_percent else ""
|
||||||
|
role = node.role or "ROUTER"
|
||||||
|
lines.append(f" {status} {node.short_name or nid[:4]} ({role}) - last seen {age}{bat}")
|
||||||
|
if not node.is_online:
|
||||||
|
lines[-1] += " <- OFFLINE"
|
||||||
|
|
||||||
|
# Channel utilization by locality
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Channel Utilization: {rs.util_percent:.0f}%")
|
||||||
|
if region.localities:
|
||||||
|
lines.append(" Localities:")
|
||||||
|
for loc in region.localities:
|
||||||
|
node_count = len(loc.node_ids)
|
||||||
|
lines.append(f" {loc.name}: {loc.score.util_percent:.0f}% - {node_count} nodes")
|
||||||
|
|
||||||
|
# Flagged nodes in this region
|
||||||
|
flagged_in_region = []
|
||||||
|
for nid in region.node_ids:
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if node and node.non_text_packets > self.health_engine.packet_threshold:
|
||||||
|
flagged_in_region.append(node)
|
||||||
|
|
||||||
|
if flagged_in_region:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Flagged Nodes:")
|
||||||
|
for node in flagged_in_region[:5]:
|
||||||
|
lines.append(f" {node.short_name or node.node_id[:4]}: {node.non_text_packets} non-text pkts/24h")
|
||||||
|
|
||||||
|
# Power warnings in this region
|
||||||
|
low_bat = []
|
||||||
|
for nid in region.node_ids:
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if node and node.battery_percent is not None and node.battery_percent < self.health_engine.battery_warning_percent:
|
||||||
|
low_bat.append(node)
|
||||||
|
|
||||||
|
if low_bat:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Power:")
|
||||||
|
bat_str = ", ".join(f"{n.short_name or n.node_id[:4]} at {n.battery_percent:.0f}%" for n in low_bat[:4])
|
||||||
|
lines.append(f" Low battery: {bat_str}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def build_node_detail(self, node_identifier: str) -> str:
|
||||||
|
"""Build detailed info for a specific node.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node_identifier: Shortname, longname, nodeId, or nodeNum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted node detail string
|
||||||
|
"""
|
||||||
|
health = self.health_engine.mesh_health
|
||||||
|
if not health:
|
||||||
|
return f"NODE DETAIL: {node_identifier}\nNo data available."
|
||||||
|
|
||||||
|
# Find node (multiple match strategies)
|
||||||
|
node = self._find_node(node_identifier)
|
||||||
|
if not node:
|
||||||
|
return f"NODE DETAIL: {node_identifier}\nNode not found."
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"NODE DETAIL: {node.long_name or node.short_name} ({node.short_name})",
|
||||||
|
f"ID: {node.node_id}",
|
||||||
|
f"Hardware: {node.role or 'Unknown'}",
|
||||||
|
f"Role: {'Infrastructure' if node.is_infrastructure else 'Client'}",
|
||||||
|
f"Region: {node.region or 'Unknown'} / Locality: {node.locality or 'Unknown'}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if node.latitude and node.longitude:
|
||||||
|
lines.append(f"Position: {node.latitude:.4f}, {node.longitude:.4f}")
|
||||||
|
|
||||||
|
age = _format_age(node.last_seen)
|
||||||
|
status = "Online" if node.is_online else "OFFLINE"
|
||||||
|
lines.append(f"Last Seen: {age} ({status})")
|
||||||
|
|
||||||
|
# Get source info from source manager
|
||||||
|
all_nodes = self.source_manager.get_all_nodes()
|
||||||
|
sources = []
|
||||||
|
for n in all_nodes:
|
||||||
|
nid = str(n.get("id") or n.get("nodeId") or n.get("num") or "")
|
||||||
|
if nid == node.node_id:
|
||||||
|
sources = n.get("_sources", [])
|
||||||
|
break
|
||||||
|
if sources:
|
||||||
|
lines.append(f"Sources: {', '.join(sources)}")
|
||||||
|
|
||||||
|
# Traffic stats
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Traffic (24h):")
|
||||||
|
lines.append(f" Total packets: {node.packet_count_24h}")
|
||||||
|
lines.append(f" Text messages: {node.text_packet_count_24h}")
|
||||||
|
lines.append(f" Non-text: {node.non_text_packets}")
|
||||||
|
|
||||||
|
# Power
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Power:")
|
||||||
|
if node.battery_percent is not None:
|
||||||
|
bat_status = "Low" if node.battery_percent < 20 else "OK"
|
||||||
|
lines.append(f" Battery: {node.battery_percent:.0f}% ({bat_status})")
|
||||||
|
else:
|
||||||
|
lines.append(" Battery: N/A")
|
||||||
|
if node.voltage:
|
||||||
|
lines.append(f" Voltage: {node.voltage:.2f}V")
|
||||||
|
lines.append(f" Solar: {'Yes' if node.has_solar else 'Unknown'}")
|
||||||
|
|
||||||
|
# Recommendations for this node
|
||||||
|
recs = self._node_recommendations(node)
|
||||||
|
if recs:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Recommendations:")
|
||||||
|
for rec in recs:
|
||||||
|
lines.append(f" - {rec}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _node_recommendations(self, node) -> list[str]:
|
||||||
|
"""Generate recommendations for a specific node."""
|
||||||
|
recs = []
|
||||||
|
|
||||||
|
# High packet count
|
||||||
|
if node.non_text_packets > self.health_engine.packet_threshold:
|
||||||
|
ratio = node.non_text_packets / self.health_engine.packet_threshold
|
||||||
|
recs.append(f"Sending {ratio:.1f}x normal packets. Check position/telemetry intervals.")
|
||||||
|
|
||||||
|
# Low battery
|
||||||
|
if node.battery_percent is not None and node.battery_percent < 20:
|
||||||
|
recs.append(f"Battery at {node.battery_percent:.0f}%. Consider charging or adding solar.")
|
||||||
|
|
||||||
|
# Offline
|
||||||
|
if not node.is_online:
|
||||||
|
age = _format_age(node.last_seen)
|
||||||
|
recs.append(f"Node offline since {age}. Check power and connectivity.")
|
||||||
|
|
||||||
|
return recs
|
||||||
|
|
||||||
|
def build_recommendations(self, scope: str, scope_value: str = None) -> str:
|
||||||
|
"""Generate actionable optimization recommendations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: "mesh", "region", or "node"
|
||||||
|
scope_value: Region name or node identifier (for scoped recommendations)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted recommendations string
|
||||||
|
"""
|
||||||
|
health = self.health_engine.mesh_health
|
||||||
|
if not health:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
recs = []
|
||||||
|
|
||||||
|
if scope == "node" and scope_value:
|
||||||
|
node = self._find_node(scope_value)
|
||||||
|
if node:
|
||||||
|
recs.extend(self._node_recommendations(node))
|
||||||
|
|
||||||
|
elif scope == "region" and scope_value:
|
||||||
|
region = self._find_region(scope_value)
|
||||||
|
if region:
|
||||||
|
recs.extend(self._region_recommendations(region, health))
|
||||||
|
|
||||||
|
else: # mesh scope
|
||||||
|
recs.extend(self._mesh_recommendations(health))
|
||||||
|
|
||||||
|
if not recs:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = ["OPTIMIZATION RECOMMENDATIONS:"]
|
||||||
|
for rec in recs[:5]:
|
||||||
|
lines.append(f" - {rec}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _region_recommendations(self, region, health) -> list[str]:
|
||||||
|
"""Generate recommendations for a region."""
|
||||||
|
recs = []
|
||||||
|
|
||||||
|
# High utilization
|
||||||
|
if region.score.util_percent >= 20:
|
||||||
|
recs.append(f"Channel utilization at {region.score.util_percent:.0f}%. Consider spreading nodes across frequencies or reducing telemetry intervals.")
|
||||||
|
|
||||||
|
# Offline infrastructure
|
||||||
|
offline_count = region.score.infra_total - region.score.infra_online
|
||||||
|
if offline_count > 0:
|
||||||
|
recs.append(f"{offline_count} infrastructure node(s) offline. Check power and connectivity.")
|
||||||
|
|
||||||
|
# Flagged nodes
|
||||||
|
flagged = []
|
||||||
|
for nid in region.node_ids:
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if node and node.non_text_packets > self.health_engine.packet_threshold:
|
||||||
|
flagged.append(node)
|
||||||
|
if flagged:
|
||||||
|
names = ", ".join(n.short_name or n.node_id[:4] for n in flagged[:3])
|
||||||
|
recs.append(f"High-traffic nodes ({names}) impacting channel. Review their telemetry settings.")
|
||||||
|
|
||||||
|
return recs
|
||||||
|
|
||||||
|
def _mesh_recommendations(self, health) -> list[str]:
|
||||||
|
"""Generate mesh-wide recommendations."""
|
||||||
|
recs = []
|
||||||
|
|
||||||
|
# Overall utilization
|
||||||
|
if health.score.util_percent >= 20:
|
||||||
|
recs.append(f"Mesh-wide utilization at {health.score.util_percent:.0f}%. Consider reducing position/telemetry broadcast frequency.")
|
||||||
|
|
||||||
|
# Multiple regions with issues
|
||||||
|
problem_regions = [r for r in health.regions if r.score.composite < 75]
|
||||||
|
if len(problem_regions) > 1:
|
||||||
|
names = ", ".join(r.name for r in problem_regions[:3])
|
||||||
|
recs.append(f"Multiple regions degraded ({names}). Prioritize infrastructure improvements.")
|
||||||
|
|
||||||
|
# High packet nodes mesh-wide
|
||||||
|
flagged = self.health_engine.get_flagged_nodes()
|
||||||
|
if len(flagged) > 3:
|
||||||
|
total_excess = sum(n.non_text_packets - self.health_engine.packet_threshold for n in flagged)
|
||||||
|
recs.append(f"{len(flagged)} nodes exceeding packet threshold ({total_excess} excess packets/day). Review default telemetry intervals.")
|
||||||
|
|
||||||
|
# Battery warnings
|
||||||
|
battery_warnings = self.health_engine.get_battery_warnings()
|
||||||
|
if len(battery_warnings) > 2:
|
||||||
|
recs.append(f"{len(battery_warnings)} nodes with low battery. Consider solar additions for remote nodes.")
|
||||||
|
|
||||||
|
return recs
|
||||||
|
|
||||||
|
def build_lora_compact(self, scope: str, scope_value: str = None) -> str:
|
||||||
|
"""Build LoRa-optimized compact summary (~200 chars).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: "mesh" or "region"
|
||||||
|
scope_value: Region name if scope is "region"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compact formatted string
|
||||||
|
"""
|
||||||
|
health = self.health_engine.mesh_health
|
||||||
|
if not health:
|
||||||
|
return "Mesh: No data"
|
||||||
|
|
||||||
|
if scope == "region" and scope_value:
|
||||||
|
region = self._find_region(scope_value)
|
||||||
|
if not region:
|
||||||
|
return f"Region '{scope_value}' not found"
|
||||||
|
rs = region.score
|
||||||
|
return f"{region.name} {rs.composite:.0f}/100 | {rs.infra_online}/{rs.infra_total} infra | {rs.util_percent:.0f}% util"
|
||||||
|
|
||||||
|
# Mesh summary
|
||||||
|
s = health.score
|
||||||
|
lines = [f"Mesh {s.composite:.0f}/100 | {s.infra_online}/{s.infra_total} infra | {s.util_percent:.0f}% util"]
|
||||||
|
|
||||||
|
# Add warnings for problem regions/nodes
|
||||||
|
warnings = []
|
||||||
|
for region in health.regions:
|
||||||
|
if region.score.composite < 60:
|
||||||
|
offline = region.score.infra_total - region.score.infra_online
|
||||||
|
warnings.append(f"! {region.name} {region.score.composite:.0f}/100 - {offline} infra offline")
|
||||||
|
|
||||||
|
battery_warnings = self.health_engine.get_battery_warnings()
|
||||||
|
for node in battery_warnings[:2]:
|
||||||
|
warnings.append(f"! {node.short_name or node.node_id[:4]} bat {node.battery_percent:.0f}%")
|
||||||
|
|
||||||
|
for w in warnings[:2]:
|
||||||
|
lines.append(w)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _find_region(self, name: str):
|
||||||
|
"""Find a region by fuzzy name match."""
|
||||||
|
health = self.health_engine.mesh_health
|
||||||
|
if not health:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name_lower = name.lower().strip()
|
||||||
|
|
||||||
|
# Exact match first
|
||||||
|
for region in health.regions:
|
||||||
|
if region.name.lower() == name_lower:
|
||||||
|
return region
|
||||||
|
|
||||||
|
# Substring match
|
||||||
|
for region in health.regions:
|
||||||
|
if name_lower in region.name.lower():
|
||||||
|
return region
|
||||||
|
|
||||||
|
# Try matching against anchor city names
|
||||||
|
for anchor in self.health_engine.regions:
|
||||||
|
# Check if search term matches anchor city or region name
|
||||||
|
anchor_name_lower = anchor.name.lower()
|
||||||
|
if name_lower in anchor_name_lower:
|
||||||
|
# Find the corresponding region
|
||||||
|
for region in health.regions:
|
||||||
|
if region.name == anchor.name:
|
||||||
|
return region
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_node(self, identifier: str):
|
||||||
|
"""Find a node by shortname, longname, nodeId, or nodeNum."""
|
||||||
|
health = self.health_engine.mesh_health
|
||||||
|
if not health:
|
||||||
|
return None
|
||||||
|
|
||||||
|
identifier = identifier.strip()
|
||||||
|
id_lower = identifier.lower()
|
||||||
|
|
||||||
|
# Try shortname (case-insensitive)
|
||||||
|
for node in health.nodes.values():
|
||||||
|
if node.short_name and node.short_name.lower() == id_lower:
|
||||||
|
return node
|
||||||
|
|
||||||
|
# Try longname (substring)
|
||||||
|
for node in health.nodes.values():
|
||||||
|
if node.long_name and id_lower in node.long_name.lower():
|
||||||
|
return node
|
||||||
|
|
||||||
|
# Try exact nodeId
|
||||||
|
if identifier in health.nodes:
|
||||||
|
return health.nodes[identifier]
|
||||||
|
|
||||||
|
# Try hex nodeId with ! prefix
|
||||||
|
if identifier.startswith("!"):
|
||||||
|
hex_id = identifier[1:]
|
||||||
|
for nid, node in health.nodes.items():
|
||||||
|
if nid.lower() == hex_id.lower():
|
||||||
|
return node
|
||||||
|
|
||||||
|
# Try decimal nodeNum
|
||||||
|
if identifier.isdigit():
|
||||||
|
# Convert to hex and search
|
||||||
|
try:
|
||||||
|
hex_id = format(int(identifier), 'x')
|
||||||
|
for nid, node in health.nodes.items():
|
||||||
|
if hex_id in nid.lower():
|
||||||
|
return node
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_regions_compact(self) -> str:
|
||||||
|
"""List all regions with scores in compact format."""
|
||||||
|
health = self.health_engine.mesh_health
|
||||||
|
if not health or not health.regions:
|
||||||
|
return "No regions configured."
|
||||||
|
|
||||||
|
lines = ["Regions:"]
|
||||||
|
for region in health.regions:
|
||||||
|
s = region.score
|
||||||
|
flag = _tier_flag(s.tier)
|
||||||
|
lines.append(f" {region.name}: {s.composite:.0f}/100{flag}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
816
meshai/router.py
816
meshai/router.py
|
|
@ -1,344 +1,472 @@
|
||||||
"""Message routing logic for MeshAI."""
|
"""Message routing logic for MeshAI."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .backends.base import LLMBackend
|
from .backends.base import LLMBackend
|
||||||
from .commands import CommandContext, CommandDispatcher
|
from .commands import CommandContext, CommandDispatcher
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .connector import MeshConnector, MeshMessage
|
from .connector import MeshConnector, MeshMessage
|
||||||
from .context import MeshContext
|
from .context import MeshContext
|
||||||
from .history import ConversationHistory
|
from .history import ConversationHistory
|
||||||
from .chunker import chunk_response, ContinuationState
|
from .chunker import chunk_response, ContinuationState
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RouteType(Enum):
|
class RouteType(Enum):
|
||||||
"""Type of message routing."""
|
"""Type of message routing."""
|
||||||
|
|
||||||
IGNORE = auto() # Don't respond
|
IGNORE = auto() # Don't respond
|
||||||
COMMAND = auto() # Bang command
|
COMMAND = auto() # Bang command
|
||||||
LLM = auto() # Route to LLM
|
LLM = auto() # Route to LLM
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RouteResult:
|
class RouteResult:
|
||||||
"""Result of routing decision."""
|
"""Result of routing decision."""
|
||||||
|
|
||||||
route_type: RouteType
|
route_type: RouteType
|
||||||
response: Optional[str] = None # For commands, the response
|
response: Optional[str] = None # For commands, the response
|
||||||
query: Optional[str] = None # For LLM, the cleaned query
|
query: Optional[str] = None # For LLM, the cleaned query
|
||||||
|
|
||||||
|
|
||||||
# advBBS protocol and notification prefixes to ignore
|
# advBBS protocol and notification prefixes to ignore
|
||||||
ADVBBS_PREFIXES = (
|
ADVBBS_PREFIXES = (
|
||||||
"MAILREQ|", "MAILACK|", "MAILNAK|", "MAILDAT|", "MAILDLV|",
|
"MAILREQ|", "MAILACK|", "MAILNAK|", "MAILDAT|", "MAILDLV|",
|
||||||
"BOARDREQ|", "BOARDACK|", "BOARDNAK|", "BOARDDAT|", "BOARDDLV|",
|
"BOARDREQ|", "BOARDACK|", "BOARDNAK|", "BOARDDAT|", "BOARDDLV|",
|
||||||
"advBBS|",
|
"advBBS|",
|
||||||
"[MAIL]",
|
"[MAIL]",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Patterns that suggest prompt injection attempts
|
# Patterns that suggest prompt injection attempts
|
||||||
_INJECTION_PATTERNS = [
|
_INJECTION_PATTERNS = [
|
||||||
re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE),
|
re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE),
|
||||||
re.compile(r"ignore\s+your\s+instructions", re.IGNORECASE),
|
re.compile(r"ignore\s+your\s+instructions", re.IGNORECASE),
|
||||||
re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE),
|
re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE),
|
||||||
re.compile(r"you\s+are\s+now\b", re.IGNORECASE),
|
re.compile(r"you\s+are\s+now\b", re.IGNORECASE),
|
||||||
re.compile(r"new\s+instructions?\s*:", re.IGNORECASE),
|
re.compile(r"new\s+instructions?\s*:", re.IGNORECASE),
|
||||||
re.compile(r"system\s*prompt\s*:", re.IGNORECASE),
|
re.compile(r"system\s*prompt\s*:", re.IGNORECASE),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Keywords that indicate mesh-related questions
|
||||||
class MessageRouter:
|
_MESH_KEYWORDS = {
|
||||||
"""Routes incoming messages to appropriate handlers."""
|
"mesh", "network", "health", "nodes", "node", "utilization", "signal",
|
||||||
|
"coverage", "battery", "solar", "offline", "router", "channel", "packet",
|
||||||
def __init__(
|
"hop", "optimize", "optimization", "infrastructure", "infra", "relay",
|
||||||
self,
|
"repeater", "region", "locality", "congestion", "collision", "airtime",
|
||||||
config: Config,
|
"telemetry", "firmware", "subscribe", "alert", "snr", "rssi",
|
||||||
connector: MeshConnector,
|
}
|
||||||
history: ConversationHistory,
|
|
||||||
dispatcher: CommandDispatcher,
|
# Phrases that indicate mesh questions
|
||||||
llm_backend: LLMBackend,
|
_MESH_PHRASES = [
|
||||||
context: MeshContext = None,
|
"how's the mesh",
|
||||||
meshmonitor_sync=None,
|
"hows the mesh",
|
||||||
knowledge=None,
|
"mesh status",
|
||||||
source_manager=None,
|
"what's wrong",
|
||||||
health_engine=None,
|
"whats wrong",
|
||||||
):
|
"check node",
|
||||||
self.config = config
|
"node status",
|
||||||
self.connector = connector
|
"network health",
|
||||||
self.history = history
|
"mesh health",
|
||||||
self.dispatcher = dispatcher
|
]
|
||||||
self.llm = llm_backend
|
|
||||||
self.context = context
|
# Mesh awareness instruction for LLM
|
||||||
self.meshmonitor_sync = meshmonitor_sync
|
_MESH_AWARENESS_PROMPT = """
|
||||||
self.knowledge = knowledge
|
When the user asks about mesh health, network status, or optimization:
|
||||||
self.source_manager = source_manager
|
- Use the LIVE MESH HEALTH DATA injected above to answer with real numbers
|
||||||
self.health_engine = health_engine
|
- Be specific: name nodes, cite utilization percentages, reference actual scores
|
||||||
self.continuations = ContinuationState(max_continuations=3)
|
- Give actionable recommendations based on the data
|
||||||
|
- If asked about a region or node you have detail for, use that detail
|
||||||
|
- If asked about something the data doesn't cover, say so - don't fabricate
|
||||||
def should_respond(self, message: MeshMessage) -> bool:
|
- Keep responses concise - these go over LoRa with limited message size
|
||||||
"""Determine if we should respond to this message.
|
- Users can run !health for a quick mesh summary or !region [name] for regional info
|
||||||
|
"""
|
||||||
DM-only bot: ignores all public channel messages.
|
|
||||||
Commands and conversational LLM responses both work in DMs.
|
|
||||||
|
class MessageRouter:
|
||||||
Args:
|
"""Routes incoming messages to appropriate handlers."""
|
||||||
message: Incoming message
|
|
||||||
|
def __init__(
|
||||||
Returns:
|
self,
|
||||||
True if we should process this message
|
config: Config,
|
||||||
"""
|
connector: MeshConnector,
|
||||||
# Always ignore our own messages
|
history: ConversationHistory,
|
||||||
if message.sender_id == self.connector.my_node_id:
|
dispatcher: CommandDispatcher,
|
||||||
return False
|
llm_backend: LLMBackend,
|
||||||
|
context: MeshContext = None,
|
||||||
# Only respond to DMs
|
meshmonitor_sync=None,
|
||||||
if not message.is_dm:
|
knowledge=None,
|
||||||
return False
|
source_manager=None,
|
||||||
|
health_engine=None,
|
||||||
if not self.config.bot.respond_to_dms:
|
mesh_reporter=None,
|
||||||
return False
|
):
|
||||||
|
self.config = config
|
||||||
# Ignore advBBS protocol and notification messages
|
self.connector = connector
|
||||||
if self.config.bot.filter_bbs_protocols:
|
self.history = history
|
||||||
if any(message.text.startswith(p) for p in ADVBBS_PREFIXES):
|
self.dispatcher = dispatcher
|
||||||
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
|
self.llm = llm_backend
|
||||||
return False
|
self.context = context
|
||||||
|
self.meshmonitor_sync = meshmonitor_sync
|
||||||
# Ignore messages that MeshMonitor will handle
|
self.knowledge = knowledge
|
||||||
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
|
self.source_manager = source_manager
|
||||||
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
|
self.health_engine = health_engine
|
||||||
return False
|
self.mesh_reporter = mesh_reporter
|
||||||
|
self.continuations = ContinuationState(max_continuations=3)
|
||||||
return True
|
|
||||||
|
def should_respond(self, message: MeshMessage) -> bool:
|
||||||
def check_continuation(self, message) -> list[str] | None:
|
"""Determine if we should respond to this message.
|
||||||
"""Check if this is a continuation request and return messages if so.
|
|
||||||
|
DM-only bot: ignores all public channel messages.
|
||||||
Returns:
|
Commands and conversational LLM responses both work in DMs.
|
||||||
List of messages to send, or None if not a continuation
|
|
||||||
"""
|
Args:
|
||||||
user_id = message.sender_id
|
message: Incoming message
|
||||||
text = message.text.strip()
|
|
||||||
|
Returns:
|
||||||
logger.info(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}")
|
True if we should process this message
|
||||||
|
"""
|
||||||
if self.continuations.has_pending(user_id):
|
# Always ignore our own messages
|
||||||
if self.continuations.is_continuation_request(text):
|
if message.sender_id == self.connector.my_node_id:
|
||||||
result = self.continuations.get_continuation(user_id)
|
return False
|
||||||
if result:
|
|
||||||
messages, _ = result
|
# Only respond to DMs
|
||||||
return messages
|
if not message.is_dm:
|
||||||
# Max continuations reached, return None to fall through
|
return False
|
||||||
else:
|
|
||||||
# User asked something new, clear pending continuation
|
if not self.config.bot.respond_to_dms:
|
||||||
self.continuations.clear(user_id)
|
return False
|
||||||
|
|
||||||
return None
|
# Ignore advBBS protocol and notification messages
|
||||||
|
if self.config.bot.filter_bbs_protocols:
|
||||||
async def route(self, message: MeshMessage) -> RouteResult:
|
if any(message.text.startswith(p) for p in ADVBBS_PREFIXES):
|
||||||
"""Route a message and generate response.
|
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
|
||||||
|
return False
|
||||||
Args:
|
|
||||||
message: Incoming message to route
|
# Ignore messages that MeshMonitor will handle
|
||||||
|
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
|
||||||
Returns:
|
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
|
||||||
RouteResult with routing decision and any response
|
return False
|
||||||
"""
|
|
||||||
text = message.text.strip()
|
return True
|
||||||
|
|
||||||
# Check for bang command first
|
def check_continuation(self, message) -> list[str] | None:
|
||||||
if self.dispatcher.is_command(text):
|
"""Check if this is a continuation request and return messages if so.
|
||||||
context = self._make_command_context(message)
|
|
||||||
response = await self.dispatcher.dispatch(text, context)
|
Returns:
|
||||||
return RouteResult(RouteType.COMMAND, response=response)
|
List of messages to send, or None if not a continuation
|
||||||
|
"""
|
||||||
# Clean up the message (remove @mention)
|
user_id = message.sender_id
|
||||||
query = self._clean_query(text)
|
text = message.text.strip()
|
||||||
|
|
||||||
if not query:
|
logger.debug(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}")
|
||||||
return RouteResult(RouteType.IGNORE)
|
|
||||||
|
if self.continuations.has_pending(user_id):
|
||||||
# Route to LLM
|
if self.continuations.is_continuation_request(text):
|
||||||
return RouteResult(RouteType.LLM, query=query)
|
result = self.continuations.get_continuation(user_id)
|
||||||
|
if result:
|
||||||
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
messages, _ = result
|
||||||
"""Generate LLM response for a message.
|
return messages
|
||||||
|
# Max continuations reached, return None to fall through
|
||||||
Args:
|
else:
|
||||||
message: Original message
|
# User asked something new, clear pending continuation
|
||||||
query: Cleaned query text
|
self.continuations.clear(user_id)
|
||||||
|
|
||||||
Returns:
|
return None
|
||||||
Generated response
|
|
||||||
"""
|
async def route(self, message: MeshMessage) -> RouteResult:
|
||||||
# Add user message to history
|
"""Route a message and generate response.
|
||||||
await self.history.add_message(message.sender_id, "user", query)
|
|
||||||
|
Args:
|
||||||
# Get conversation history
|
message: Incoming message to route
|
||||||
history = await self.history.get_history_for_llm(message.sender_id)
|
|
||||||
|
Returns:
|
||||||
# Build system prompt in order: identity -> static -> meshmonitor -> context
|
RouteResult with routing decision and any response
|
||||||
|
"""
|
||||||
# 1. Dynamic identity from bot config
|
text = message.text.strip()
|
||||||
bot_name = self.config.bot.name or "MeshAI"
|
|
||||||
bot_owner = self.config.bot.owner or "Unknown"
|
# Check for bang command first
|
||||||
|
if self.dispatcher.is_command(text):
|
||||||
identity = (
|
context = self._make_command_context(message)
|
||||||
f"You are {bot_name}, an LLM-powered conversational assistant running on a "
|
response = await self.dispatcher.dispatch(text, context)
|
||||||
f"Meshtastic mesh network. Your managing operator is {bot_owner}. "
|
return RouteResult(RouteType.COMMAND, response=response)
|
||||||
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
|
||||||
f"IDENTITY: Your name is {bot_name}. You respond to DMs only. You connect "
|
# Clean up the message (remove @mention)
|
||||||
f"to a Meshtastic node via TCP through meshtasticd.\n\n"
|
query = self._clean_query(text)
|
||||||
)
|
|
||||||
|
if not query:
|
||||||
# 2. Static system prompt from config
|
return RouteResult(RouteType.IGNORE)
|
||||||
static_prompt = ""
|
|
||||||
if getattr(self.config.llm, 'use_system_prompt', True):
|
# Route to LLM
|
||||||
static_prompt = self.config.llm.system_prompt
|
return RouteResult(RouteType.LLM, query=query)
|
||||||
|
|
||||||
system_prompt = identity + static_prompt
|
def _is_mesh_question(self, message: str) -> bool:
|
||||||
|
"""Check if message is asking about mesh health/status.
|
||||||
# 3. MeshMonitor info (only when enabled)
|
|
||||||
if (
|
Args:
|
||||||
self.meshmonitor_sync
|
message: User message text
|
||||||
and self.config.meshmonitor.enabled
|
|
||||||
and self.config.meshmonitor.inject_into_prompt
|
Returns:
|
||||||
):
|
True if this is a mesh-related question
|
||||||
meshmonitor_intro = (
|
"""
|
||||||
"\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same "
|
msg_lower = message.lower()
|
||||||
"meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, "
|
|
||||||
"traceroutes, security scanning, and auto-responder commands. Its trigger "
|
# Check for mesh phrases
|
||||||
"commands are listed below — if someone asks what commands are available, "
|
for phrase in _MESH_PHRASES:
|
||||||
"mention both yours and MeshMonitor's. If someone asks where to get "
|
if phrase in msg_lower:
|
||||||
"MeshMonitor, direct them to github.com/Yeraze/meshmonitor"
|
return True
|
||||||
)
|
|
||||||
system_prompt += meshmonitor_intro
|
# Check for mesh keywords
|
||||||
|
words = set(re.findall(r'\b\w+\b', msg_lower))
|
||||||
commands_summary = self.meshmonitor_sync.get_commands_summary()
|
if words & _MESH_KEYWORDS:
|
||||||
if commands_summary:
|
return True
|
||||||
system_prompt += "\n\n" + commands_summary
|
|
||||||
|
return False
|
||||||
# 4. Inject mesh context if available
|
|
||||||
if self.context:
|
def _detect_mesh_scope(self, message: str) -> tuple[str, Optional[str]]:
|
||||||
max_items = getattr(self.config.context, 'max_context_items', 20)
|
"""Detect the scope of a mesh question.
|
||||||
context_block = self.context.get_context_block(max_items=max_items)
|
|
||||||
if context_block:
|
Args:
|
||||||
system_prompt += (
|
message: User message text
|
||||||
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
|
|
||||||
+ context_block
|
Returns:
|
||||||
)
|
Tuple of (scope_type, scope_value):
|
||||||
else:
|
- ("node", "{identifier}") if asking about specific node
|
||||||
system_prompt += (
|
- ("region", "{region_name}") if asking about specific region
|
||||||
"\n\n[No recent mesh traffic observed yet.]"
|
- ("mesh", None) for general mesh questions
|
||||||
)
|
"""
|
||||||
|
msg_lower = message.lower()
|
||||||
|
|
||||||
|
# Check for node references
|
||||||
# 5. Knowledge base retrieval
|
if self.health_engine and self.health_engine.mesh_health:
|
||||||
if self.knowledge and query:
|
health = self.health_engine.mesh_health
|
||||||
results = self.knowledge.search(query)
|
|
||||||
if results:
|
# Look for node shortnames (4 chars, case-insensitive)
|
||||||
chunks = "\n\n".join(
|
for node in health.nodes.values():
|
||||||
f"[{r['title']}]: {r['excerpt']}" for r in results
|
if node.short_name:
|
||||||
)
|
# Check if shortname appears as a word in message
|
||||||
system_prompt += (
|
pattern = r'\b' + re.escape(node.short_name.lower()) + r'\b'
|
||||||
"\n\nREFERENCE KNOWLEDGE - Answer using this information:\n"
|
if re.search(pattern, msg_lower):
|
||||||
+ chunks
|
return ("node", node.short_name)
|
||||||
)
|
|
||||||
|
# Check longname substring
|
||||||
# DEBUG: Log system prompt status
|
if node.long_name and node.long_name.lower() in msg_lower:
|
||||||
logger.warning(f"SYSTEM PROMPT LENGTH: {len(system_prompt)} chars")
|
return ("node", node.short_name or node.node_id)
|
||||||
logger.warning(f"HAS REFERENCE KNOWLEDGE: {'REFERENCE KNOWLEDGE' in system_prompt}")
|
|
||||||
try:
|
# Check for region references
|
||||||
response = await self.llm.generate(
|
if self.health_engine:
|
||||||
messages=history,
|
for anchor in self.health_engine.regions:
|
||||||
system_prompt=system_prompt,
|
anchor_lower = anchor.name.lower()
|
||||||
max_tokens=500,
|
# Check region name
|
||||||
)
|
if anchor_lower in msg_lower:
|
||||||
except asyncio.TimeoutError:
|
return ("region", anchor.name)
|
||||||
logger.error("LLM request timed out")
|
|
||||||
response = "Sorry, request timed out. Try again."
|
# Check parts of region name (e.g., "wood river" matches "Wood River - ID")
|
||||||
except Exception as e:
|
parts = anchor_lower.replace("-", " ").replace("–", " ").split()
|
||||||
logger.error(f"LLM generation error: {e}")
|
for part in parts:
|
||||||
response = "Sorry, I encountered an error. Please try again."
|
if len(part) > 3 and part in msg_lower:
|
||||||
|
return ("region", anchor.name)
|
||||||
# Add assistant response to history
|
|
||||||
await self.history.add_message(message.sender_id, "assistant", response)
|
return ("mesh", None)
|
||||||
|
|
||||||
# Persist summary if one was created/updated
|
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
||||||
await self._persist_summary(message.sender_id)
|
"""Generate LLM response for a message.
|
||||||
|
|
||||||
# Chunk the response with sentence awareness
|
Args:
|
||||||
messages, remaining = chunk_response(
|
message: Original message
|
||||||
response,
|
query: Cleaned query text
|
||||||
max_chars=self.config.response.max_length,
|
|
||||||
max_messages=self.config.response.max_messages,
|
Returns:
|
||||||
)
|
Generated response
|
||||||
|
"""
|
||||||
# Store remaining content for continuation
|
# Add user message to history
|
||||||
if remaining:
|
await self.history.add_message(message.sender_id, "user", query)
|
||||||
logger.info(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining")
|
|
||||||
self.continuations.store(message.sender_id, remaining)
|
# Get conversation history
|
||||||
else:
|
history = await self.history.get_history_for_llm(message.sender_id)
|
||||||
logger.info(f"No remaining content for {message.sender_id}")
|
|
||||||
|
# Build system prompt in order: identity -> static -> meshmonitor -> context -> knowledge -> mesh
|
||||||
return messages
|
|
||||||
|
# 1. Dynamic identity from bot config
|
||||||
async def _persist_summary(self, user_id: str) -> None:
|
bot_name = self.config.bot.name or "MeshAI"
|
||||||
"""Persist any cached summary to the database.
|
bot_owner = self.config.bot.owner or "Unknown"
|
||||||
|
|
||||||
Args:
|
identity = (
|
||||||
user_id: User identifier
|
f"You are {bot_name}, an LLM-powered conversational assistant running on a "
|
||||||
"""
|
f"Meshtastic mesh network. Your managing operator is {bot_owner}. "
|
||||||
memory = self.llm.get_memory()
|
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
||||||
if not memory:
|
f"IDENTITY: Your name is {bot_name}. You respond to DMs only. You connect "
|
||||||
return
|
f"to a Meshtastic node via TCP through meshtasticd.\n\n"
|
||||||
|
)
|
||||||
summary = memory.get_cached_summary(user_id)
|
|
||||||
if summary:
|
# 2. Static system prompt from config
|
||||||
await self.history.store_summary(
|
static_prompt = ""
|
||||||
user_id,
|
if getattr(self.config.llm, 'use_system_prompt', True):
|
||||||
summary.summary,
|
static_prompt = self.config.llm.system_prompt
|
||||||
summary.message_count,
|
|
||||||
)
|
system_prompt = identity + static_prompt
|
||||||
logger.debug(f"Persisted summary for {user_id}")
|
|
||||||
|
# 3. MeshMonitor info (only when enabled)
|
||||||
def _clean_query(self, text: str) -> str:
|
if (
|
||||||
"""Clean up query text and check for prompt injection."""
|
self.meshmonitor_sync
|
||||||
cleaned = " ".join(text.split())
|
and self.config.meshmonitor.enabled
|
||||||
cleaned = cleaned.strip()
|
and self.config.meshmonitor.inject_into_prompt
|
||||||
|
):
|
||||||
# Check for prompt injection
|
meshmonitor_intro = (
|
||||||
for pattern in _INJECTION_PATTERNS:
|
"\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same "
|
||||||
if pattern.search(cleaned):
|
"meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, "
|
||||||
logger.warning(
|
"traceroutes, security scanning, and auto-responder commands. Its trigger "
|
||||||
f"Possible prompt injection detected: {cleaned[:80]}..."
|
"commands are listed below — if someone asks what commands are available, "
|
||||||
)
|
"mention both yours and MeshMonitor's. If someone asks where to get "
|
||||||
match = pattern.search(cleaned)
|
"MeshMonitor, direct them to github.com/Yeraze/meshmonitor"
|
||||||
cleaned = cleaned[:match.start()].strip()
|
)
|
||||||
if not cleaned:
|
system_prompt += meshmonitor_intro
|
||||||
cleaned = "Hello"
|
|
||||||
break
|
commands_summary = self.meshmonitor_sync.get_commands_summary()
|
||||||
|
if commands_summary:
|
||||||
return cleaned
|
system_prompt += "\n\n" + commands_summary
|
||||||
|
|
||||||
def _make_command_context(self, message: MeshMessage) -> CommandContext:
|
# 4. Inject mesh context if available
|
||||||
"""Create command context from message."""
|
if self.context:
|
||||||
return CommandContext(
|
max_items = getattr(self.config.context, 'max_context_items', 20)
|
||||||
sender_id=message.sender_id,
|
context_block = self.context.get_context_block(max_items=max_items)
|
||||||
sender_name=message.sender_name,
|
if context_block:
|
||||||
channel=message.channel,
|
system_prompt += (
|
||||||
is_dm=message.is_dm,
|
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
|
||||||
position=message.sender_position,
|
+ context_block
|
||||||
config=self.config,
|
)
|
||||||
connector=self.connector,
|
else:
|
||||||
history=self.history,
|
system_prompt += (
|
||||||
)
|
"\n\n[No recent mesh traffic observed yet.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Knowledge base retrieval
|
||||||
|
if self.knowledge and query:
|
||||||
|
results = self.knowledge.search(query)
|
||||||
|
if results:
|
||||||
|
chunks = "\n\n".join(
|
||||||
|
f"[{r['title']}]: {r['excerpt']}" for r in results
|
||||||
|
)
|
||||||
|
system_prompt += (
|
||||||
|
"\n\nREFERENCE KNOWLEDGE - Answer using this information:\n"
|
||||||
|
+ chunks
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Mesh Intelligence (inject health data for mesh questions)
|
||||||
|
if (
|
||||||
|
self.source_manager
|
||||||
|
and self.mesh_reporter
|
||||||
|
and self._is_mesh_question(query)
|
||||||
|
):
|
||||||
|
scope_type, scope_value = self._detect_mesh_scope(query)
|
||||||
|
|
||||||
|
# Always include Tier 1 summary for mesh questions
|
||||||
|
tier1 = self.mesh_reporter.build_tier1_summary()
|
||||||
|
system_prompt += "\n\n" + tier1
|
||||||
|
|
||||||
|
# Add Tier 2 detail if scoped
|
||||||
|
if scope_type == "region" and scope_value:
|
||||||
|
region_detail = self.mesh_reporter.build_region_detail(scope_value)
|
||||||
|
system_prompt += "\n\n" + region_detail
|
||||||
|
elif scope_type == "node" and scope_value:
|
||||||
|
node_detail = self.mesh_reporter.build_node_detail(scope_value)
|
||||||
|
system_prompt += "\n\n" + node_detail
|
||||||
|
|
||||||
|
# Always include relevant recommendations
|
||||||
|
recommendations = self.mesh_reporter.build_recommendations(scope_type, scope_value)
|
||||||
|
if recommendations:
|
||||||
|
system_prompt += "\n\n" + recommendations
|
||||||
|
|
||||||
|
# Add mesh awareness instructions
|
||||||
|
system_prompt += _MESH_AWARENESS_PROMPT
|
||||||
|
|
||||||
|
# DEBUG: Log system prompt status
|
||||||
|
logger.debug(f"System prompt length: {len(system_prompt)} chars")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.llm.generate(
|
||||||
|
messages=history,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
max_tokens=500,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("LLM request timed out")
|
||||||
|
response = "Sorry, request timed out. Try again."
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM generation error: {e}")
|
||||||
|
response = "Sorry, I encountered an error. Please try again."
|
||||||
|
|
||||||
|
# Add assistant response to history
|
||||||
|
await self.history.add_message(message.sender_id, "assistant", response)
|
||||||
|
|
||||||
|
# Persist summary if one was created/updated
|
||||||
|
await self._persist_summary(message.sender_id)
|
||||||
|
|
||||||
|
# Chunk the response with sentence awareness
|
||||||
|
messages, remaining = chunk_response(
|
||||||
|
response,
|
||||||
|
max_chars=self.config.response.max_length,
|
||||||
|
max_messages=self.config.response.max_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store remaining content for continuation
|
||||||
|
if remaining:
|
||||||
|
logger.debug(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining")
|
||||||
|
self.continuations.store(message.sender_id, remaining)
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
async def _persist_summary(self, user_id: str) -> None:
|
||||||
|
"""Persist any cached summary to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
"""
|
||||||
|
memory = self.llm.get_memory()
|
||||||
|
if not memory:
|
||||||
|
return
|
||||||
|
|
||||||
|
summary = memory.get_cached_summary(user_id)
|
||||||
|
if summary:
|
||||||
|
await self.history.store_summary(
|
||||||
|
user_id,
|
||||||
|
summary.summary,
|
||||||
|
summary.message_count,
|
||||||
|
)
|
||||||
|
logger.debug(f"Persisted summary for {user_id}")
|
||||||
|
|
||||||
|
def _clean_query(self, text: str) -> str:
|
||||||
|
"""Clean up query text and check for prompt injection."""
|
||||||
|
cleaned = " ".join(text.split())
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
# Check for prompt injection
|
||||||
|
for pattern in _INJECTION_PATTERNS:
|
||||||
|
if pattern.search(cleaned):
|
||||||
|
logger.warning(
|
||||||
|
f"Possible prompt injection detected: {cleaned[:80]}..."
|
||||||
|
)
|
||||||
|
match = pattern.search(cleaned)
|
||||||
|
cleaned = cleaned[:match.start()].strip()
|
||||||
|
if not cleaned:
|
||||||
|
cleaned = "Hello"
|
||||||
|
break
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def _make_command_context(self, message: MeshMessage) -> CommandContext:
|
||||||
|
"""Create command context from message."""
|
||||||
|
return CommandContext(
|
||||||
|
sender_id=message.sender_id,
|
||||||
|
sender_name=message.sender_name,
|
||||||
|
channel=message.channel,
|
||||||
|
is_dm=message.is_dm,
|
||||||
|
position=message.sender_position,
|
||||||
|
config=self.config,
|
||||||
|
connector=self.connector,
|
||||||
|
history=self.history,
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue