feat: Phase 1 — multi-source data aggregation from Meshview and MeshMonitor APIs

- Add MeshviewSource class for fetching nodes, edges, stats from Meshview API
- Add MeshMonitorDataSource class for fetching nodes, channels, telemetry,
  traceroutes, network stats, topology, packets, solar from MeshMonitor API
- Add MeshSourceManager for managing multiple sources with aggregation
- Add MeshSourceConfig dataclass and mesh_sources list to config
- Integrate source_manager into main.py with periodic refresh
- Add source_manager parameter to MessageRouter (for future Phase 3)
- Add Mesh Sources TUI menu with add/edit/remove/test functionality
- Update config.example.yaml with mesh_sources section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-04 16:26:58 +00:00
commit b945558ba3
9 changed files with 2830 additions and 1856 deletions

View file

@ -71,7 +71,7 @@ weather:
# === MESHMONITOR INTEGRATION === # === MESHMONITOR INTEGRATION ===
meshmonitor: meshmonitor:
enabled: false # Enable MeshMonitor trigger sync enabled: false # Enable MeshMonitor trigger sync
url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:8080) url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333)
inject_into_prompt: true # Include trigger list in LLM prompt inject_into_prompt: true # Include trigger list in LLM prompt
refresh_interval: 300 # Seconds between trigger refreshes refresh_interval: 300 # Seconds between trigger refreshes
@ -80,5 +80,23 @@ knowledge:
enabled: false # Enable knowledge base search enabled: false # Enable knowledge base search
db_path: "" # Path to knowledge SQLite database db_path: "" # Path to knowledge SQLite database
top_k: 5 # Number of chunks to retrieve per query top_k: 5 # Number of chunks to retrieve per query
fts_weight: 0.5 # Weight for FTS5 keyword matches (0-1)
vector_weight: 0.5 # Weight for vector semantic matches (0-1) # === MESH DATA SOURCES ===
# Connect to Meshview and/or MeshMonitor instances for live mesh
# network analysis. Supports multiple sources. Configure via TUI
# with meshai --config (Mesh Sources menu).
#
# mesh_sources:
# - name: "my-meshview"
# type: meshview
# url: "https://meshview.example.com"
# refresh_interval: 300
# enabled: true
#
# - name: "my-meshmonitor"
# type: meshmonitor
# url: "http://192.168.1.100:3333"
# api_token: "${MM_API_TOKEN}"
# refresh_interval: 300
# enabled: true
mesh_sources: []

File diff suppressed because it is too large Load diff

View file

@ -1,290 +1,315 @@
"""Configuration management for MeshAI.""" """Configuration management for MeshAI."""
import logging import logging
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import yaml import yaml
_config_logger = logging.getLogger(__name__) _config_logger = logging.getLogger(__name__)
@dataclass @dataclass
class BotConfig: class BotConfig:
"""Bot identity and trigger settings.""" """Bot identity and trigger settings."""
name: str = "ai" name: str = "ai"
owner: str = "" owner: str = ""
respond_to_dms: bool = True respond_to_dms: bool = True
filter_bbs_protocols: bool = True filter_bbs_protocols: bool = True
@dataclass @dataclass
class ConnectionConfig: class ConnectionConfig:
"""Meshtastic connection settings.""" """Meshtastic connection settings."""
type: str = "serial" # serial or tcp type: str = "serial" # serial or tcp
serial_port: str = "/dev/ttyUSB0" serial_port: str = "/dev/ttyUSB0"
tcp_host: str = "192.168.1.100" tcp_host: str = "192.168.1.100"
tcp_port: int = 4403 tcp_port: int = 4403
@dataclass @dataclass
class ResponseConfig: class ResponseConfig:
"""Response behavior settings.""" """Response behavior settings."""
delay_min: float = 2.2 delay_min: float = 2.2
delay_max: float = 3.0 delay_max: float = 3.0
max_length: int = 150 max_length: int = 150
max_messages: int = 2 max_messages: int = 2
@dataclass @dataclass
class HistoryConfig: class HistoryConfig:
"""Conversation history settings.""" """Conversation history settings."""
database: str = "conversations.db" database: str = "conversations.db"
max_messages_per_user: int = 50 max_messages_per_user: int = 50
conversation_timeout: int = 86400 # 24 hours conversation_timeout: int = 86400 # 24 hours
# Cleanup settings # Cleanup settings
auto_cleanup: bool = True auto_cleanup: bool = True
cleanup_interval_hours: int = 24 cleanup_interval_hours: int = 24
max_age_days: int = 30 # Delete conversations older than this max_age_days: int = 30 # Delete conversations older than this
@dataclass @dataclass
class MemoryConfig: class MemoryConfig:
"""Rolling summary memory settings.""" """Rolling summary memory settings."""
enabled: bool = True # Enable memory optimization enabled: bool = True # Enable memory optimization
window_size: int = 4 # Recent message pairs to keep in full window_size: int = 4 # Recent message pairs to keep in full
summarize_threshold: int = 8 # Messages before re-summarizing summarize_threshold: int = 8 # Messages before re-summarizing
@dataclass @dataclass
class ContextConfig: class ContextConfig:
"""Passive mesh context settings.""" """Passive mesh context settings."""
enabled: bool = True enabled: bool = True
observe_channels: list[int] = field(default_factory=list) # Empty = all channels observe_channels: list[int] = field(default_factory=list) # Empty = all channels
ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore
max_age: int = 2_592_000 # 30 days in seconds max_age: int = 2_592_000 # 30 days in seconds
max_context_items: int = 20 # Max observations injected into LLM context max_context_items: int = 20 # Max observations injected into LLM context
@dataclass @dataclass
class CommandsConfig: class CommandsConfig:
"""Command settings.""" """Command settings."""
enabled: bool = True enabled: bool = True
prefix: str = "!" prefix: str = "!"
disabled_commands: list[str] = field(default_factory=list) disabled_commands: list[str] = field(default_factory=list)
custom_commands: dict = field(default_factory=dict) custom_commands: dict = field(default_factory=dict)
@dataclass @dataclass
class LLMConfig: class LLMConfig:
"""LLM backend settings.""" """LLM backend settings."""
backend: str = "openai" # openai, anthropic, google backend: str = "openai" # openai, anthropic, google
api_key: str = "" api_key: str = ""
base_url: str = "https://api.openai.com/v1" base_url: str = "https://api.openai.com/v1"
model: str = "gpt-4o-mini" model: str = "gpt-4o-mini"
timeout: int = 30 timeout: int = 30
system_prompt: str = ( system_prompt: str = (
"YOUR COMMANDS (handled directly by you via DM):\n" "YOUR COMMANDS (handled directly by you via DM):\n"
"!help — List available commands.\n" "!help — List available commands.\n"
"!ping — Connectivity test, responds with pong.\n" "!ping — Connectivity test, responds with pong.\n"
"!status — Shows your version, uptime, user count, and message count.\n" "!status — Shows your version, uptime, user count, and message count.\n"
"!weather [location] — Weather lookup using Open-Meteo API.\n" "!weather [location] — Weather lookup using Open-Meteo API.\n"
"!reset — Clears conversation history and memory.\n" "!reset — Clears conversation history and memory.\n"
"!clear — Same as !reset.\n\n" "!clear — Same as !reset.\n\n"
"YOUR ARCHITECTURE: Modular Python — pluggable LLM backends (OpenAI, Anthropic, " "YOUR ARCHITECTURE: Modular Python — pluggable LLM backends (OpenAI, Anthropic, "
"Google, local), per-user SQLite conversation history, rolling summary memory, " "Google, local), per-user SQLite conversation history, rolling summary memory, "
"passive mesh context buffer (observes channel traffic), smart chunking for LoRa " "passive mesh context buffer (observes channel traffic), smart chunking for LoRa "
"message limits, prompt injection defense, advBBS filtering.\n\n" "message limits, prompt injection defense, advBBS filtering.\n\n"
"RESPONSE RULES:\n" "RESPONSE RULES:\n"
"- Keep responses very brief — 1-2 short sentences, under 300 characters. Only give longer answers if the user explicitly asks for detail or explanation.\n" "- Keep responses very brief — 1-2 short sentences, under 300 characters. Only give longer answers if the user explicitly asks for detail or explanation.\n"
"- Be concise but friendly. No markdown formatting.\n" "- Be concise but friendly. No markdown formatting.\n"
"- If asked about mesh activity and no recent traffic is shown, say you haven't " "- If asked about mesh activity and no recent traffic is shown, say you haven't "
"observed any yet.\n" "observed any yet.\n"
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n" "- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
"- You are part of the freq51 mesh in the Twin Falls, Idaho area." "- You are part of the freq51 mesh in the Twin Falls, Idaho area."
) )
use_system_prompt: bool = True # Toggle to disable sending system prompt use_system_prompt: bool = True # Toggle to disable sending system prompt
web_search: bool = False # Enable web search (Open WebUI feature) web_search: bool = False # Enable web search (Open WebUI feature)
google_grounding: bool = False # Enable Google Search grounding (Gemini only) google_grounding: bool = False # Enable Google Search grounding (Gemini only)
@dataclass @dataclass
class OpenMeteoConfig: class OpenMeteoConfig:
"""Open-Meteo weather provider settings.""" """Open-Meteo weather provider settings."""
url: str = "https://api.open-meteo.com/v1" url: str = "https://api.open-meteo.com/v1"
@dataclass @dataclass
class WttrConfig: class WttrConfig:
"""wttr.in weather provider settings.""" """wttr.in weather provider settings."""
url: str = "https://wttr.in" url: str = "https://wttr.in"
@dataclass @dataclass
class WeatherConfig: class WeatherConfig:
"""Weather command settings.""" """Weather command settings."""
primary: str = "openmeteo" # openmeteo, wttr, llm primary: str = "openmeteo" # openmeteo, wttr, llm
fallback: str = "llm" # openmeteo, wttr, llm, none fallback: str = "llm" # openmeteo, wttr, llm, none
default_location: str = "" default_location: str = ""
openmeteo: OpenMeteoConfig = field(default_factory=OpenMeteoConfig) openmeteo: OpenMeteoConfig = field(default_factory=OpenMeteoConfig)
wttr: WttrConfig = field(default_factory=WttrConfig) wttr: WttrConfig = field(default_factory=WttrConfig)
@dataclass @dataclass
class MeshMonitorConfig: class MeshMonitorConfig:
"""MeshMonitor trigger sync settings.""" """MeshMonitor trigger sync settings."""
enabled: bool = False enabled: bool = False
url: str = "" # e.g., http://100.64.0.11:3333 url: str = "" # e.g., http://100.64.0.11:3333
inject_into_prompt: bool = True # Tell LLM about MeshMonitor commands inject_into_prompt: bool = True # Tell LLM about MeshMonitor commands
refresh_interval: int = 300 # Seconds between refreshes refresh_interval: int = 300 # Seconds between refreshes
@dataclass @dataclass
class KnowledgeConfig: class KnowledgeConfig:
"""FTS5 knowledge base settings.""" """FTS5 knowledge base settings."""
enabled: bool = False enabled: bool = False
db_path: str = "" db_path: str = ""
top_k: int = 5 top_k: int = 5
@dataclass
class Config: @dataclass
"""Main configuration container.""" class MeshSourceConfig:
"""Configuration for a mesh data source."""
bot: BotConfig = field(default_factory=BotConfig)
connection: ConnectionConfig = field(default_factory=ConnectionConfig) name: str = ""
response: ResponseConfig = field(default_factory=ResponseConfig) type: str = "" # "meshview" or "meshmonitor"
history: HistoryConfig = field(default_factory=HistoryConfig) url: str = ""
memory: MemoryConfig = field(default_factory=MemoryConfig) api_token: str = "" # MeshMonitor only, supports ${ENV_VAR}
context: ContextConfig = field(default_factory=ContextConfig) refresh_interval: int = 300
commands: CommandsConfig = field(default_factory=CommandsConfig) enabled: bool = True
llm: LLMConfig = field(default_factory=LLMConfig)
weather: WeatherConfig = field(default_factory=WeatherConfig)
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig) @dataclass
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig) class Config:
"""Main configuration container."""
_config_path: Optional[Path] = field(default=None, repr=False)
bot: BotConfig = field(default_factory=BotConfig)
def resolve_api_key(self) -> str: connection: ConnectionConfig = field(default_factory=ConnectionConfig)
"""Resolve API key from config or environment.""" response: ResponseConfig = field(default_factory=ResponseConfig)
if self.llm.api_key: history: HistoryConfig = field(default_factory=HistoryConfig)
# Check if it's an env var reference like ${LLM_API_KEY} memory: MemoryConfig = field(default_factory=MemoryConfig)
if self.llm.api_key.startswith("${") and self.llm.api_key.endswith("}"): context: ContextConfig = field(default_factory=ContextConfig)
env_var = self.llm.api_key[2:-1] commands: CommandsConfig = field(default_factory=CommandsConfig)
return os.environ.get(env_var, "") llm: LLMConfig = field(default_factory=LLMConfig)
return self.llm.api_key weather: WeatherConfig = field(default_factory=WeatherConfig)
# Fall back to common env vars meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
for env_var in ["LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]: knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
if value := os.environ.get(env_var): mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
return value
return "" _config_path: Optional[Path] = field(default=None, repr=False)
def resolve_api_key(self) -> str:
def _dict_to_dataclass(cls, data: dict): """Resolve API key from config or environment."""
"""Recursively convert dict to dataclass, handling nested structures.""" if self.llm.api_key:
if data is None: # Check if it's an env var reference like ${LLM_API_KEY}
return cls() if self.llm.api_key.startswith("${") and self.llm.api_key.endswith("}"):
env_var = self.llm.api_key[2:-1]
field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()} return os.environ.get(env_var, "")
kwargs = {} return self.llm.api_key
# Fall back to common env vars
for key, value in data.items(): for env_var in ["LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
if key.startswith("_"): if value := os.environ.get(env_var):
continue return value
if key not in field_types: return ""
continue
field_type = field_types[key] def _dict_to_dataclass(cls, data: dict):
"""Recursively convert dict to dataclass, handling nested structures."""
# Handle nested dataclasses if data is None:
if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict): return cls()
kwargs[key] = _dict_to_dataclass(field_type, value)
else: field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
kwargs[key] = value kwargs = {}
return cls(**kwargs) for key, value in data.items():
if key.startswith("_"):
continue
def _dataclass_to_dict(obj) -> dict: if key not in field_types:
"""Recursively convert dataclass to dict for YAML serialization.""" continue
if not hasattr(obj, "__dataclass_fields__"):
return obj field_type = field_types[key]
result = {} # Handle nested dataclasses
for field_name in obj.__dataclass_fields__: if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
if field_name.startswith("_"): kwargs[key] = _dict_to_dataclass(field_type, value)
continue # Handle list of MeshSourceConfig
value = getattr(obj, field_name) elif key == "mesh_sources" and isinstance(value, list):
if hasattr(value, "__dataclass_fields__"): kwargs[key] = [
result[field_name] = _dataclass_to_dict(value) _dict_to_dataclass(MeshSourceConfig, item)
elif isinstance(value, list): if isinstance(item, dict) else item
result[field_name] = list(value) for item in value
else: ]
result[field_name] = value else:
return result kwargs[key] = value
return cls(**kwargs)
def load_config(config_path: Optional[Path] = None) -> Config:
"""Load configuration from YAML file.
def _dataclass_to_dict(obj) -> dict:
Args: """Recursively convert dataclass to dict for YAML serialization."""
config_path: Path to config file. Defaults to ./config.yaml if not hasattr(obj, "__dataclass_fields__"):
return obj
Returns:
Config object with loaded settings result = {}
""" for field_name in obj.__dataclass_fields__:
if config_path is None: if field_name.startswith("_"):
config_path = Path("config.yaml") continue
value = getattr(obj, field_name)
config_path = Path(config_path) if hasattr(value, "__dataclass_fields__"):
result[field_name] = _dataclass_to_dict(value)
if not config_path.exists(): elif isinstance(value, list):
# Return default config if file doesn't exist # Handle list of dataclasses (like mesh_sources)
config = Config() result[field_name] = [
config._config_path = config_path _dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
return config for item in value
]
with open(config_path, "r") as f: else:
data = yaml.safe_load(f) or {} result[field_name] = value
return result
config = _dict_to_dataclass(Config, data)
config._config_path = config_path
return config def load_config(config_path: Optional[Path] = None) -> Config:
"""Load configuration from YAML file.
def save_config(config: Config, config_path: Optional[Path] = None) -> None: Args:
"""Save configuration to YAML file. config_path: Path to config file. Defaults to ./config.yaml
Args: Returns:
config: Config object to save Config object with loaded settings
config_path: Path to save to. Uses config._config_path if not specified """
""" if config_path is None:
if config_path is None: config_path = Path("config.yaml")
config_path = config._config_path or Path("config.yaml")
config_path = Path(config_path)
config_path = Path(config_path)
if not config_path.exists():
data = _dataclass_to_dict(config) # Return default config if file doesn't exist
config = Config()
# Add header comment config._config_path = config_path
header = "# MeshAI Configuration\n# Generated by meshai --config\n\n" return config
with open(config_path, "w") as f: with open(config_path, "r") as f:
f.write(header) data = yaml.safe_load(f) or {}
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
config = _dict_to_dataclass(Config, data)
config._config_path = config_path
return config
def save_config(config: Config, config_path: Optional[Path] = None) -> None:
"""Save configuration to YAML file.
Args:
config: Config object to save
config_path: Path to save to. Uses config._config_path if not specified
"""
if config_path is None:
config_path = config._config_path or Path("config.yaml")
config_path = Path(config_path)
data = _dataclass_to_dict(config)
# Add header comment
header = "# MeshAI Configuration\n# Generated by meshai --config\n\n"
with open(config_path, "w") as f:
f.write(header)
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)

View file

@ -1,381 +1,409 @@
"""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.router: Optional[MessageRouter] = None self.source_manager = 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_cleanup: 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_cleanup = time.time()
# 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 cleanup
if time.time() - self._last_cleanup >= 3600: # Periodic mesh source refresh
await self.history.cleanup_expired() if self.source_manager:
if self.context: self.source_manager.refresh_all()
self.context.prune()
self._last_cleanup = time.time() # Periodic cleanup
if time.time() - self._last_cleanup >= 3600:
async def stop(self) -> None: await self.history.cleanup_expired()
"""Stop the bot.""" if self.context:
logger.info("Stopping MeshAI...") self.context.prune()
self._running = False self._last_cleanup = time.time()
if self.connector: async def stop(self) -> None:
self.connector.disconnect() """Stop the bot."""
logger.info("Stopping MeshAI...")
if self.history: self._running = False
await self.history.close()
if self.connector:
if self.llm: self.connector.disconnect()
await self.llm.close()
if self.knowledge: if self.history:
self.knowledge.close() await self.history.close()
self._remove_pid() if self.llm:
logger.info("MeshAI stopped") await self.llm.close()
if self.knowledge:
async def _init_components(self) -> None: self.knowledge.close()
"""Initialize all components."""
# Conversation history self._remove_pid()
self.history = ConversationHistory(self.config.history) logger.info("MeshAI stopped")
await self.history.initialize()
async def _init_components(self) -> None:
# Command dispatcher """Initialize all components."""
self.dispatcher = create_dispatcher( # Conversation history
prefix=self.config.commands.prefix, self.history = ConversationHistory(self.config.history)
disabled_commands=self.config.commands.disabled_commands, await self.history.initialize()
custom_commands=self.config.commands.custom_commands,
) # 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,
)
# Knowledge base count = self.meshmonitor_sync.load()
kb_cfg = self.config.knowledge logger.info(f"MeshMonitor sync enabled, loaded {count} triggers")
if kb_cfg.enabled and kb_cfg.db_path: else:
from .knowledge import KnowledgeSearch self.meshmonitor_sync = None
self.knowledge = KnowledgeSearch(
db_path=kb_cfg.db_path, # Mesh data sources
top_k=kb_cfg.top_k, enabled_sources = [s for s in self.config.mesh_sources if s.enabled]
) if enabled_sources:
else: from .mesh_sources import MeshSourceManager
self.knowledge = None self.source_manager = MeshSourceManager(enabled_sources)
# Initial fetch
# Message router self.source_manager.refresh_all()
self.router = MessageRouter( # Log status
self.config, self.connector, self.history, self.dispatcher, self.llm, for status in self.source_manager.get_status():
context=self.context, if status["is_loaded"]:
meshmonitor_sync=self.meshmonitor_sync, logger.info(
knowledge=self.knowledge, f"Mesh source '{status['name']}' ({status['type']}): "
) f"{status['node_count']} nodes"
)
# Responder else:
self.responder = Responder(self.config.response, self.connector) logger.warning(
f"Mesh source '{status['name']}' ({status['type']}): "
async def _on_message(self, message: MeshMessage) -> None: f"failed - {status.get('last_error', 'unknown error')}"
"""Handle incoming message.""" )
try: else:
# Passively observe channel broadcasts for context (before filtering) self.source_manager = None
if self.context and not message.is_dm and message.text:
self.context.observe( # Knowledge base
sender_name=message.sender_name, kb_cfg = self.config.knowledge
sender_id=message.sender_id, if kb_cfg.enabled and kb_cfg.db_path:
text=message.text, from .knowledge import KnowledgeSearch
channel=message.channel, self.knowledge = KnowledgeSearch(
is_dm=False, db_path=kb_cfg.db_path,
) top_k=kb_cfg.top_k,
)
# Check if we should respond else:
if not self.router.should_respond(message): self.knowledge = None
return
# Message router
logger.info( self.router = MessageRouter(
f"Processing message from {message.sender_name} ({message.sender_id}): " self.config, self.connector, self.history, self.dispatcher, self.llm,
f"{message.text[:50]}..." context=self.context,
) meshmonitor_sync=self.meshmonitor_sync,
knowledge=self.knowledge,
# Route the message source_manager=self.source_manager,
# Check for continuation request first )
continuation_messages = self.router.check_continuation(message)
if continuation_messages: # Responder
await self.responder.send_response( self.responder = Responder(self.config.response, self.connector)
continuation_messages,
destination=message.sender_id, async def _on_message(self, message: MeshMessage) -> None:
channel=message.channel, """Handle incoming message."""
) try:
return # Passively observe channel broadcasts for context (before filtering)
if self.context and not message.is_dm and message.text:
result = await self.router.route(message) self.context.observe(
sender_name=message.sender_name,
if result.route_type == RouteType.IGNORE: sender_id=message.sender_id,
return text=message.text,
channel=message.channel,
# Determine response is_dm=False,
if result.route_type == RouteType.COMMAND: )
messages = result.response # Commands return single string
elif result.route_type == RouteType.LLM: # Check if we should respond
messages = await self.router.generate_llm_response(message, result.query) if not self.router.should_respond(message):
else: return
return
logger.info(
if not messages: f"Processing message from {message.sender_name} ({message.sender_id}): "
return f"{message.text[:50]}..."
)
# Send DM response
await self.responder.send_response( # Route the message
messages, # Check for continuation request first
destination=message.sender_id, continuation_messages = self.router.check_continuation(message)
channel=message.channel, if continuation_messages:
) await self.responder.send_response(
continuation_messages,
except Exception as e: destination=message.sender_id,
logger.error(f"Error handling message: {e}", exc_info=True) channel=message.channel,
)
async def _load_summaries(self) -> None: return
"""Load persisted summaries from database into memory cache."""
memory = self.llm.get_memory() result = await self.router.route(message)
if not memory:
return if result.route_type == RouteType.IGNORE:
return
if not self.history or not self.history._db:
return # Determine response
if result.route_type == RouteType.COMMAND:
try: messages = result.response # Commands return single string
async with self.history._lock: elif result.route_type == RouteType.LLM:
cursor = await self.history._db.execute( messages = await self.router.generate_llm_response(message, result.query)
"SELECT user_id, summary, message_count, updated_at " else:
"FROM conversation_summaries" return
)
rows = await cursor.fetchall() if not messages:
return
loaded = 0
for row in rows: # Send DM response
user_id, summary_text, message_count, updated_at = row await self.responder.send_response(
summary = ConversationSummary( messages,
summary=summary_text, destination=message.sender_id,
last_updated=updated_at, channel=message.channel,
message_count=message_count, )
)
memory.load_summary(user_id, summary) except Exception as e:
loaded += 1 logger.error(f"Error handling message: {e}", exc_info=True)
if loaded: async def _load_summaries(self) -> None:
logger.info(f"Loaded {loaded} conversation summaries from database") """Load persisted summaries from database into memory cache."""
memory = self.llm.get_memory()
except Exception as e: if not memory:
logger.warning(f"Failed to load summaries from database: {e}") return
def _write_pid(self) -> None: if not self.history or not self.history._db:
"""Write PID file.""" return
pid_file = Path("/tmp/meshai.pid")
pid_file.write_text(str(os.getpid())) try:
async with self.history._lock:
def _remove_pid(self) -> None: cursor = await self.history._db.execute(
"""Remove PID file.""" "SELECT user_id, summary, message_count, updated_at "
pid_file = Path("/tmp/meshai.pid") "FROM conversation_summaries"
if pid_file.exists(): )
pid_file.unlink() rows = await cursor.fetchall()
loaded = 0
def setup_logging(verbose: bool = False) -> None: for row in rows:
"""Configure logging.""" user_id, summary_text, message_count, updated_at = row
level = logging.DEBUG if verbose else logging.INFO summary = ConversationSummary(
logging.basicConfig( summary=summary_text,
level=level, last_updated=updated_at,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", message_count=message_count,
datefmt="%Y-%m-%d %H:%M:%S", )
) memory.load_summary(user_id, summary)
loaded += 1
def main() -> None: if loaded:
"""Main entry point.""" logger.info(f"Loaded {loaded} conversation summaries from database")
parser = argparse.ArgumentParser(
description="MeshAI - LLM-powered Meshtastic assistant", except Exception as e:
prog="meshai", logger.warning(f"Failed to load summaries from database: {e}")
)
parser.add_argument( def _write_pid(self) -> None:
"--version", "-V", action="version", version=f"%(prog)s {__version__}" """Write PID file."""
) pid_file = Path("/tmp/meshai.pid")
parser.add_argument( pid_file.write_text(str(os.getpid()))
"--config", "-c", action="store_true", help="Launch configuration tool"
) def _remove_pid(self) -> None:
parser.add_argument( """Remove PID file."""
"--config-file", pid_file = Path("/tmp/meshai.pid")
"-f", if pid_file.exists():
type=Path, pid_file.unlink()
default=Path("config.yaml"),
help="Path to config file (default: config.yaml)",
) def setup_logging(verbose: bool = False) -> None:
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") """Configure logging."""
level = logging.DEBUG if verbose else logging.INFO
args = parser.parse_args() logging.basicConfig(
level=level,
setup_logging(args.verbose) format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
# Launch configurator if requested )
if args.config:
run_configurator(args.config_file)
return def main() -> None:
"""Main entry point."""
# Load config parser = argparse.ArgumentParser(
config = load_config(args.config_file) description="MeshAI - LLM-powered Meshtastic assistant",
prog="meshai",
# Check if config exists )
if not args.config_file.exists(): parser.add_argument(
logger.warning(f"Config file not found: {args.config_file}") "--version", "-V", action="version", version=f"%(prog)s {__version__}"
logger.info("Run 'meshai --config' to create one, or copy config.example.yaml") )
sys.exit(1) parser.add_argument(
"--config", "-c", action="store_true", help="Launch configuration tool"
# Create and run bot )
bot = MeshAI(config) parser.add_argument(
"--config-file",
# Handle signals "-f",
loop = asyncio.new_event_loop() type=Path,
asyncio.set_event_loop(loop) default=Path("config.yaml"),
help="Path to config file (default: config.yaml)",
def signal_handler(sig, frame): )
logger.info(f"Received signal {sig}") parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
loop.create_task(bot.stop())
args = parser.parse_args()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) setup_logging(args.verbose)
try: # Launch configurator if requested
loop.run_until_complete(bot.start()) if args.config:
except KeyboardInterrupt: run_configurator(args.config_file)
pass return
finally:
loop.run_until_complete(bot.stop()) # Load config
loop.close() config = load_config(args.config_file)
# Check if config exists
if __name__ == "__main__": if not args.config_file.exists():
main() logger.warning(f"Config file not found: {args.config_file}")
logger.info("Run 'meshai --config' to create one, or copy config.example.yaml")
sys.exit(1)
# Create and run bot
bot = MeshAI(config)
# Handle signals
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
def signal_handler(sig, frame):
logger.info(f"Received signal {sig}")
loop.create_task(bot.stop())
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
loop.run_until_complete(bot.start())
except KeyboardInterrupt:
pass
finally:
loop.run_until_complete(bot.stop())
loop.close()
if __name__ == "__main__":
main()

197
meshai/mesh_sources.py Normal file
View file

@ -0,0 +1,197 @@
"""Mesh data source manager."""
import logging
import time
from typing import Optional
from .config import MeshSourceConfig
from .sources.meshview import MeshviewSource
from .sources.meshmonitor_data import MeshMonitorDataSource
logger = logging.getLogger(__name__)
class MeshSourceManager:
"""Manages multiple mesh data sources."""
def __init__(self, source_configs: list[MeshSourceConfig]):
"""Initialize source manager.
Args:
source_configs: List of source configurations
"""
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource] = {}
for cfg in source_configs:
if not cfg.enabled:
continue
name = cfg.name
if not name:
logger.warning("Skipping source with empty name")
continue
if name in self._sources:
logger.warning(f"Duplicate source name '{name}', skipping")
continue
try:
if cfg.type == "meshview":
self._sources[name] = MeshviewSource(
url=cfg.url,
refresh_interval=cfg.refresh_interval,
)
logger.info(f"Created Meshview source '{name}' -> {cfg.url}")
elif cfg.type == "meshmonitor":
self._sources[name] = MeshMonitorDataSource(
url=cfg.url,
api_token=cfg.api_token,
refresh_interval=cfg.refresh_interval,
)
logger.info(f"Created MeshMonitor source '{name}' -> {cfg.url}")
else:
logger.warning(f"Unknown source type '{cfg.type}' for '{name}'")
except Exception as e:
logger.error(f"Failed to create source '{name}': {e}")
def refresh_all(self) -> int:
"""Call maybe_refresh() on all sources.
Returns:
Number of sources that refreshed
"""
refreshed = 0
for name, source in self._sources.items():
try:
if source.maybe_refresh():
refreshed += 1
except Exception as e:
logger.error(f"Error refreshing source '{name}': {e}")
return refreshed
def get_source(self, name: str) -> Optional[MeshviewSource | MeshMonitorDataSource]:
"""Get a specific source by name.
Args:
name: Source name
Returns:
Source instance or None if not found
"""
return self._sources.get(name)
def get_all_nodes(self) -> list[dict]:
"""Get nodes from all sources, tagged with source name.
Returns:
List of node dicts with '_source' field added
"""
all_nodes = []
for name, source in self._sources.items():
for node in source.nodes:
tagged = dict(node)
tagged["_source"] = name
all_nodes.append(tagged)
return all_nodes
def get_all_edges(self) -> list[dict]:
"""Get edges from all Meshview sources, tagged with source name.
Returns:
List of edge dicts with '_source' field added
"""
all_edges = []
for name, source in self._sources.items():
if isinstance(source, MeshviewSource):
for edge in source.edges:
tagged = dict(edge)
tagged["_source"] = name
all_edges.append(tagged)
return all_edges
def get_all_telemetry(self) -> list[dict]:
"""Get telemetry from all MeshMonitor sources, tagged with source name.
Returns:
List of telemetry dicts with '_source' field added
"""
all_telemetry = []
for name, source in self._sources.items():
if isinstance(source, MeshMonitorDataSource):
for item in source.telemetry:
tagged = dict(item)
tagged["_source"] = name
all_telemetry.append(tagged)
return all_telemetry
def get_all_traceroutes(self) -> list[dict]:
"""Get traceroutes from all MeshMonitor sources, tagged with source name.
Returns:
List of traceroute dicts with '_source' field added
"""
all_traceroutes = []
for name, source in self._sources.items():
if isinstance(source, MeshMonitorDataSource):
for item in source.traceroutes:
tagged = dict(item)
tagged["_source"] = name
all_traceroutes.append(tagged)
return all_traceroutes
def get_all_channels(self) -> list[dict]:
"""Get channels from all MeshMonitor sources, tagged with source name.
Returns:
List of channel dicts with '_source' field added
"""
all_channels = []
for name, source in self._sources.items():
if isinstance(source, MeshMonitorDataSource):
for item in source.channels:
tagged = dict(item)
tagged["_source"] = name
all_channels.append(tagged)
return all_channels
def get_status(self) -> list[dict]:
"""Get status of all sources for TUI display.
Returns:
List of status dicts with source info
"""
status_list = []
for name, source in self._sources.items():
status = {
"name": name,
"type": "meshview" if isinstance(source, MeshviewSource) else "meshmonitor",
"enabled": True,
"is_loaded": source.is_loaded,
"last_refresh": source.last_refresh,
"last_error": source.last_error,
"node_count": len(source.nodes),
}
if isinstance(source, MeshviewSource):
status["edge_count"] = len(source.edges)
elif isinstance(source, MeshMonitorDataSource):
status["telemetry_count"] = len(source.telemetry)
status["traceroute_count"] = len(source.traceroutes)
status["channel_count"] = len(source.channels)
status_list.append(status)
return status_list
@property
def source_count(self) -> int:
"""Get number of active sources."""
return len(self._sources)
@property
def source_names(self) -> list[str]:
"""Get list of source names."""
return list(self._sources.keys())

View file

@ -1,340 +1,342 @@
"""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),
] ]
class MessageRouter: class MessageRouter:
"""Routes incoming messages to appropriate handlers.""" """Routes incoming messages to appropriate handlers."""
def __init__( def __init__(
self, self,
config: Config, config: Config,
connector: MeshConnector, connector: MeshConnector,
history: ConversationHistory, history: ConversationHistory,
dispatcher: CommandDispatcher, dispatcher: CommandDispatcher,
llm_backend: LLMBackend, llm_backend: LLMBackend,
context: MeshContext = None, context: MeshContext = None,
meshmonitor_sync=None, meshmonitor_sync=None,
knowledge=None, knowledge=None,
): source_manager=None,
self.config = config ):
self.connector = connector self.config = config
self.history = history self.connector = connector
self.dispatcher = dispatcher self.history = history
self.llm = llm_backend self.dispatcher = dispatcher
self.context = context self.llm = llm_backend
self.meshmonitor_sync = meshmonitor_sync self.context = context
self.knowledge = knowledge self.meshmonitor_sync = meshmonitor_sync
self.continuations = ContinuationState(max_continuations=3) self.knowledge = knowledge
self.source_manager = source_manager # For future use in Phase 3
self.continuations = ContinuationState(max_continuations=3)
def should_respond(self, message: MeshMessage) -> bool:
"""Determine if we should respond to this message.
def should_respond(self, message: MeshMessage) -> bool:
DM-only bot: ignores all public channel messages. """Determine if we should respond to this message.
Commands and conversational LLM responses both work in DMs.
DM-only bot: ignores all public channel messages.
Args: Commands and conversational LLM responses both work in DMs.
message: Incoming message
Args:
Returns: message: Incoming message
True if we should process this message
""" Returns:
# Always ignore our own messages True if we should process this message
if message.sender_id == self.connector.my_node_id: """
return False # Always ignore our own messages
if message.sender_id == self.connector.my_node_id:
# Only respond to DMs return False
if not message.is_dm:
return False # Only respond to DMs
if not message.is_dm:
if not self.config.bot.respond_to_dms: return False
return False
if not self.config.bot.respond_to_dms:
# Ignore advBBS protocol and notification messages return False
if self.config.bot.filter_bbs_protocols:
if any(message.text.startswith(p) for p in ADVBBS_PREFIXES): # Ignore advBBS protocol and notification messages
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...") if self.config.bot.filter_bbs_protocols:
return False if any(message.text.startswith(p) for p in ADVBBS_PREFIXES):
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
# Ignore messages that MeshMonitor will handle return False
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...") # Ignore messages that MeshMonitor will handle
return False if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
return True return False
def check_continuation(self, message) -> list[str] | None: return True
"""Check if this is a continuation request and return messages if so.
def check_continuation(self, message) -> list[str] | None:
Returns: """Check if this is a continuation request and return messages if so.
List of messages to send, or None if not a continuation
""" Returns:
user_id = message.sender_id List of messages to send, or None if not a continuation
text = message.text.strip() """
user_id = message.sender_id
logger.info(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}") text = message.text.strip()
if self.continuations.has_pending(user_id): logger.info(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}")
if self.continuations.is_continuation_request(text):
result = self.continuations.get_continuation(user_id) if self.continuations.has_pending(user_id):
if result: if self.continuations.is_continuation_request(text):
messages, _ = result result = self.continuations.get_continuation(user_id)
return messages if result:
# Max continuations reached, return None to fall through messages, _ = result
else: return messages
# User asked something new, clear pending continuation # Max continuations reached, return None to fall through
self.continuations.clear(user_id) else:
# User asked something new, clear pending continuation
return None self.continuations.clear(user_id)
async def route(self, message: MeshMessage) -> RouteResult: return None
"""Route a message and generate response.
async def route(self, message: MeshMessage) -> RouteResult:
Args: """Route a message and generate response.
message: Incoming message to route
Args:
Returns: message: Incoming message to route
RouteResult with routing decision and any response
""" Returns:
text = message.text.strip() RouteResult with routing decision and any response
"""
# Check for bang command first text = message.text.strip()
if self.dispatcher.is_command(text):
context = self._make_command_context(message) # Check for bang command first
response = await self.dispatcher.dispatch(text, context) if self.dispatcher.is_command(text):
return RouteResult(RouteType.COMMAND, response=response) context = self._make_command_context(message)
response = await self.dispatcher.dispatch(text, context)
# Clean up the message (remove @mention) return RouteResult(RouteType.COMMAND, response=response)
query = self._clean_query(text)
# Clean up the message (remove @mention)
if not query: query = self._clean_query(text)
return RouteResult(RouteType.IGNORE)
if not query:
# Route to LLM return RouteResult(RouteType.IGNORE)
return RouteResult(RouteType.LLM, query=query)
# Route to LLM
async def generate_llm_response(self, message: MeshMessage, query: str) -> str: return RouteResult(RouteType.LLM, query=query)
"""Generate LLM response for a message.
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
Args: """Generate LLM response for a message.
message: Original message
query: Cleaned query text Args:
message: Original message
Returns: query: Cleaned query text
Generated response
""" Returns:
# Add user message to history Generated response
await self.history.add_message(message.sender_id, "user", query) """
# Add user message to history
# Get conversation history await self.history.add_message(message.sender_id, "user", query)
history = await self.history.get_history_for_llm(message.sender_id)
# Get conversation history
# Build system prompt in order: identity -> static -> meshmonitor -> context history = await self.history.get_history_for_llm(message.sender_id)
# 1. Dynamic identity from bot config # Build system prompt in order: identity -> static -> meshmonitor -> context
bot_name = self.config.bot.name or "MeshAI"
bot_owner = self.config.bot.owner or "Unknown" # 1. Dynamic identity from bot config
bot_name = self.config.bot.name or "MeshAI"
identity = ( bot_owner = self.config.bot.owner or "Unknown"
f"You are {bot_name}, an LLM-powered conversational assistant running on a "
f"Meshtastic mesh network. Your managing operator is {bot_owner}. " identity = (
f"You are open source at github.com/zvx-echo6/meshai.\n\n" f"You are {bot_name}, an LLM-powered conversational assistant running on a "
f"IDENTITY: Your name is {bot_name}. You respond to DMs only. You connect " f"Meshtastic mesh network. Your managing operator is {bot_owner}. "
f"to a Meshtastic node via TCP through meshtasticd.\n\n" 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 "
f"to a Meshtastic node via TCP through meshtasticd.\n\n"
# 2. Static system prompt from config )
static_prompt = ""
if getattr(self.config.llm, 'use_system_prompt', True): # 2. Static system prompt from config
static_prompt = self.config.llm.system_prompt static_prompt = ""
if getattr(self.config.llm, 'use_system_prompt', True):
system_prompt = identity + static_prompt static_prompt = self.config.llm.system_prompt
# 3. MeshMonitor info (only when enabled) system_prompt = identity + static_prompt
if (
self.meshmonitor_sync # 3. MeshMonitor info (only when enabled)
and self.config.meshmonitor.enabled if (
and self.config.meshmonitor.inject_into_prompt self.meshmonitor_sync
): and self.config.meshmonitor.enabled
meshmonitor_intro = ( and self.config.meshmonitor.inject_into_prompt
"\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same " ):
"meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, " meshmonitor_intro = (
"traceroutes, security scanning, and auto-responder commands. Its trigger " "\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same "
"commands are listed below — if someone asks what commands are available, " "meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, "
"mention both yours and MeshMonitor's. If someone asks where to get " "traceroutes, security scanning, and auto-responder commands. Its trigger "
"MeshMonitor, direct them to github.com/Yeraze/meshmonitor" "commands are listed below — if someone asks what commands are available, "
) "mention both yours and MeshMonitor's. If someone asks where to get "
system_prompt += meshmonitor_intro "MeshMonitor, direct them to github.com/Yeraze/meshmonitor"
)
commands_summary = self.meshmonitor_sync.get_commands_summary() system_prompt += meshmonitor_intro
if commands_summary:
system_prompt += "\n\n" + commands_summary commands_summary = self.meshmonitor_sync.get_commands_summary()
if commands_summary:
# 4. Inject mesh context if available system_prompt += "\n\n" + commands_summary
if self.context:
max_items = getattr(self.config.context, 'max_context_items', 20) # 4. Inject mesh context if available
context_block = self.context.get_context_block(max_items=max_items) if self.context:
if context_block: max_items = getattr(self.config.context, 'max_context_items', 20)
system_prompt += ( context_block = self.context.get_context_block(max_items=max_items)
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n" if context_block:
+ context_block system_prompt += (
) "\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
else: + context_block
system_prompt += ( )
"\n\n[No recent mesh traffic observed yet.]" else:
) system_prompt += (
"\n\n[No recent mesh traffic observed yet.]"
)
# 5. Knowledge base retrieval
if self.knowledge and query:
results = self.knowledge.search(query) # 5. Knowledge base retrieval
if results: if self.knowledge and query:
chunks = "\n\n".join( results = self.knowledge.search(query)
f"[{r['title']}]: {r['excerpt']}" for r in results if results:
) chunks = "\n\n".join(
system_prompt += ( f"[{r['title']}]: {r['excerpt']}" for r in results
"\n\nREFERENCE KNOWLEDGE - Answer using this information:\n" )
+ chunks system_prompt += (
) "\n\nREFERENCE KNOWLEDGE - Answer using this information:\n"
+ chunks
# DEBUG: Log system prompt status )
logger.warning(f"SYSTEM PROMPT LENGTH: {len(system_prompt)} chars")
logger.warning(f"HAS REFERENCE KNOWLEDGE: {'REFERENCE KNOWLEDGE' in system_prompt}") # DEBUG: Log system prompt status
try: logger.warning(f"SYSTEM PROMPT LENGTH: {len(system_prompt)} chars")
response = await self.llm.generate( logger.warning(f"HAS REFERENCE KNOWLEDGE: {'REFERENCE KNOWLEDGE' in system_prompt}")
messages=history, try:
system_prompt=system_prompt, response = await self.llm.generate(
max_tokens=500, messages=history,
) system_prompt=system_prompt,
except asyncio.TimeoutError: max_tokens=500,
logger.error("LLM request timed out") )
response = "Sorry, request timed out. Try again." except asyncio.TimeoutError:
except Exception as e: logger.error("LLM request timed out")
logger.error(f"LLM generation error: {e}") response = "Sorry, request timed out. Try again."
response = "Sorry, I encountered an error. Please try again." except Exception as e:
logger.error(f"LLM generation error: {e}")
# Add assistant response to history response = "Sorry, I encountered an error. Please try again."
await self.history.add_message(message.sender_id, "assistant", response)
# Add assistant response to history
# Persist summary if one was created/updated await self.history.add_message(message.sender_id, "assistant", response)
await self._persist_summary(message.sender_id)
# Persist summary if one was created/updated
# Chunk the response with sentence awareness await self._persist_summary(message.sender_id)
messages, remaining = chunk_response(
response, # Chunk the response with sentence awareness
max_chars=self.config.response.max_length, messages, remaining = chunk_response(
max_messages=self.config.response.max_messages, response,
) max_chars=self.config.response.max_length,
max_messages=self.config.response.max_messages,
# Store remaining content for continuation )
if remaining:
logger.info(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining") # Store remaining content for continuation
self.continuations.store(message.sender_id, remaining) if remaining:
else: logger.info(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining")
logger.info(f"No remaining content for {message.sender_id}") self.continuations.store(message.sender_id, remaining)
else:
return messages logger.info(f"No remaining content for {message.sender_id}")
async def _persist_summary(self, user_id: str) -> None: return messages
"""Persist any cached summary to the database.
async def _persist_summary(self, user_id: str) -> None:
Args: """Persist any cached summary to the database.
user_id: User identifier
""" Args:
memory = self.llm.get_memory() user_id: User identifier
if not memory: """
return memory = self.llm.get_memory()
if not memory:
summary = memory.get_cached_summary(user_id) return
if summary:
await self.history.store_summary( summary = memory.get_cached_summary(user_id)
user_id, if summary:
summary.summary, await self.history.store_summary(
summary.message_count, user_id,
) summary.summary,
logger.debug(f"Persisted summary for {user_id}") summary.message_count,
)
def _clean_query(self, text: str) -> str: logger.debug(f"Persisted summary for {user_id}")
"""Clean up query text and check for prompt injection."""
cleaned = " ".join(text.split()) def _clean_query(self, text: str) -> str:
cleaned = cleaned.strip() """Clean up query text and check for prompt injection."""
cleaned = " ".join(text.split())
# Check for prompt injection cleaned = cleaned.strip()
for pattern in _INJECTION_PATTERNS:
if pattern.search(cleaned): # Check for prompt injection
logger.warning( for pattern in _INJECTION_PATTERNS:
f"Possible prompt injection detected: {cleaned[:80]}..." if pattern.search(cleaned):
) logger.warning(
match = pattern.search(cleaned) f"Possible prompt injection detected: {cleaned[:80]}..."
cleaned = cleaned[:match.start()].strip() )
if not cleaned: match = pattern.search(cleaned)
cleaned = "Hello" cleaned = cleaned[:match.start()].strip()
break if not cleaned:
cleaned = "Hello"
return cleaned break
def _make_command_context(self, message: MeshMessage) -> CommandContext: return cleaned
"""Create command context from message."""
return CommandContext( def _make_command_context(self, message: MeshMessage) -> CommandContext:
sender_id=message.sender_id, """Create command context from message."""
sender_name=message.sender_name, return CommandContext(
channel=message.channel, sender_id=message.sender_id,
is_dm=message.is_dm, sender_name=message.sender_name,
position=message.sender_position, channel=message.channel,
config=self.config, is_dm=message.is_dm,
connector=self.connector, position=message.sender_position,
history=self.history, config=self.config,
) connector=self.connector,
history=self.history,
)

View file

@ -0,0 +1 @@
"""Mesh data source connectors."""

View file

@ -0,0 +1,257 @@
"""MeshMonitor API data source."""
import json
import logging
import os
import time
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
class MeshMonitorDataSource:
"""Fetches mesh data from a MeshMonitor instance."""
def __init__(self, url: str, api_token: str, refresh_interval: int = 300):
"""Initialize MeshMonitor data source.
Args:
url: Base URL of MeshMonitor instance (e.g., http://192.168.1.100:3333)
api_token: API token for authentication. Supports ${ENV_VAR} format.
refresh_interval: Seconds between refresh checks (default 5 minutes)
"""
self._url = url.rstrip("/")
self._api_token = self._resolve_token(api_token)
self._refresh_interval = refresh_interval
# Cached data
self._nodes: list[dict] = []
self._channels: list[dict] = []
self._telemetry: list[dict] = []
self._traceroutes: list[dict] = []
self._network_stats: Optional[dict] = None
self._topology: Optional[dict] = None
self._packets: list[dict] = []
self._solar: list[dict] = []
self._last_refresh: float = 0.0
self._last_error: Optional[str] = None
self._is_loaded: bool = False
def _resolve_token(self, token: str) -> str:
"""Resolve token, supporting ${ENV_VAR} format.
Args:
token: API token or env var reference
Returns:
Resolved token value
"""
if token.startswith("${") and token.endswith("}"):
env_var = token[2:-1]
return os.environ.get(env_var, "")
return token
@property
def nodes(self) -> list[dict]:
"""Get cached nodes list."""
return self._nodes
@property
def channels(self) -> list[dict]:
"""Get cached channels list."""
return self._channels
@property
def telemetry(self) -> list[dict]:
"""Get cached telemetry list."""
return self._telemetry
@property
def traceroutes(self) -> list[dict]:
"""Get cached traceroutes list."""
return self._traceroutes
@property
def network_stats(self) -> Optional[dict]:
"""Get cached network stats."""
return self._network_stats
@property
def topology(self) -> Optional[dict]:
"""Get cached topology."""
return self._topology
@property
def packets(self) -> list[dict]:
"""Get cached packets list."""
return self._packets
@property
def solar(self) -> list[dict]:
"""Get cached solar estimates list."""
return self._solar
@property
def last_refresh(self) -> float:
"""Get last refresh timestamp (epoch)."""
return self._last_refresh
@property
def last_error(self) -> Optional[str]:
"""Get last error message if any."""
return self._last_error
@property
def is_loaded(self) -> bool:
"""Check if data has been successfully loaded."""
return self._is_loaded
def _fetch_json(self, endpoint: str) -> Optional[dict | list]:
"""Fetch JSON from an endpoint with Bearer auth.
Args:
endpoint: API endpoint path (e.g., /api/v1/nodes)
Returns:
Parsed JSON data or None on error
"""
url = f"{self._url}{endpoint}"
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {self._api_token}",
}
try:
req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
# MeshMonitor wraps responses in {"success": true, "data": [...]}
# Extract the actual data if wrapped
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
except HTTPError as e:
logger.warning(f"MeshMonitor {endpoint}: HTTP {e.code} {e.reason}")
return None
except URLError as e:
logger.warning(f"MeshMonitor {endpoint}: Connection error - {e.reason}")
return None
except json.JSONDecodeError as e:
logger.warning(f"MeshMonitor {endpoint}: Invalid JSON - {e}")
return None
except Exception as e:
logger.warning(f"MeshMonitor {endpoint}: {e}")
return None
def fetch_all(self) -> bool:
"""Fetch all data from MeshMonitor API.
Fetches all endpoints independently. One failure doesn't block others.
Returns:
True if at least one endpoint succeeded
"""
success_count = 0
errors = []
# Fetch nodes
data = self._fetch_json("/api/v1/nodes")
if data is not None:
self._nodes = data if isinstance(data, list) else []
success_count += 1
logger.debug(f"MeshMonitor: fetched {len(self._nodes)} nodes")
else:
errors.append("nodes")
# Fetch channels
data = self._fetch_json("/api/v1/channels")
if data is not None:
self._channels = data if isinstance(data, list) else []
success_count += 1
logger.debug(f"MeshMonitor: fetched {len(self._channels)} channels")
else:
errors.append("channels")
# Fetch telemetry
data = self._fetch_json("/api/v1/telemetry")
if data is not None:
self._telemetry = data if isinstance(data, list) else []
success_count += 1
logger.debug(f"MeshMonitor: fetched {len(self._telemetry)} telemetry records")
else:
errors.append("telemetry")
# Fetch traceroutes
data = self._fetch_json("/api/v1/traceroutes")
if data is not None:
self._traceroutes = data if isinstance(data, list) else []
success_count += 1
logger.debug(f"MeshMonitor: fetched {len(self._traceroutes)} traceroutes")
else:
errors.append("traceroutes")
# Fetch network stats
data = self._fetch_json("/api/v1/network")
if data is not None:
self._network_stats = data if isinstance(data, dict) else None
success_count += 1
logger.debug("MeshMonitor: fetched network stats")
else:
errors.append("network")
# Fetch topology
data = self._fetch_json("/api/v1/network/topology")
if data is not None:
self._topology = data if isinstance(data, dict) else None
success_count += 1
logger.debug("MeshMonitor: fetched topology")
else:
errors.append("topology")
# Fetch packets
data = self._fetch_json("/api/v1/packets")
if data is not None:
self._packets = data if isinstance(data, list) else []
success_count += 1
logger.debug(f"MeshMonitor: fetched {len(self._packets)} packets")
else:
errors.append("packets")
# Fetch solar estimates
data = self._fetch_json("/api/v1/solar")
if data is not None:
self._solar = data if isinstance(data, list) else []
success_count += 1
logger.debug(f"MeshMonitor: fetched {len(self._solar)} solar estimates")
else:
errors.append("solar")
# Update state
self._last_refresh = time.time()
if success_count > 0:
self._is_loaded = True
self._last_error = None
logger.info(
f"MeshMonitor refresh: {len(self._nodes)} nodes, "
f"{len(self._telemetry)} telemetry, {len(self._traceroutes)} traceroutes"
)
return True
else:
self._last_error = f"All endpoints failed: {', '.join(errors)}"
logger.error(f"MeshMonitor: {self._last_error}")
return False
def maybe_refresh(self) -> bool:
"""Refresh data if interval has elapsed.
Returns:
True if refresh was performed
"""
if time.time() - self._last_refresh >= self._refresh_interval:
return self.fetch_all()
return False

166
meshai/sources/meshview.py Normal file
View file

@ -0,0 +1,166 @@
"""Meshview API data source."""
import json
import logging
import time
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
class MeshviewSource:
"""Fetches mesh data from a Meshview instance."""
def __init__(self, url: str, refresh_interval: int = 300):
"""Initialize Meshview source.
Args:
url: Base URL of Meshview instance (e.g., https://meshview.example.com)
refresh_interval: Seconds between refresh checks (default 5 minutes)
"""
self._url = url.rstrip("/")
self._refresh_interval = refresh_interval
self._nodes: list[dict] = []
self._edges: list[dict] = []
self._stats: Optional[dict | list] = None
self._counts: Optional[dict] = None
self._last_refresh: float = 0.0
self._last_error: Optional[str] = None
self._is_loaded: bool = False
@property
def nodes(self) -> list[dict]:
"""Get cached nodes list."""
return self._nodes
@property
def edges(self) -> list[dict]:
"""Get cached edges list."""
return self._edges
@property
def stats(self) -> Optional[dict | list]:
"""Get cached stats."""
return self._stats
@property
def counts(self) -> Optional[dict]:
"""Get cached counts."""
return self._counts
@property
def last_refresh(self) -> float:
"""Get last refresh timestamp (epoch)."""
return self._last_refresh
@property
def last_error(self) -> Optional[str]:
"""Get last error message if any."""
return self._last_error
@property
def is_loaded(self) -> bool:
"""Check if data has been successfully loaded."""
return self._is_loaded
def _fetch_json(self, endpoint: str) -> Optional[dict | list]:
"""Fetch JSON from an endpoint.
Args:
endpoint: API endpoint path (e.g., /api/nodes)
Returns:
Parsed JSON data or None on error
"""
url = f"{self._url}{endpoint}"
try:
req = Request(url, headers={"Accept": "application/json"})
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
logger.warning(f"Meshview {endpoint}: HTTP {e.code} {e.reason}")
return None
except URLError as e:
logger.warning(f"Meshview {endpoint}: Connection error - {e.reason}")
return None
except json.JSONDecodeError as e:
logger.warning(f"Meshview {endpoint}: Invalid JSON - {e}")
return None
except Exception as e:
logger.warning(f"Meshview {endpoint}: {e}")
return None
def fetch_all(self) -> bool:
"""Fetch all data from Meshview API.
Fetches nodes, edges, stats, and counts independently.
One failure doesn't block others.
Returns:
True if at least one endpoint succeeded
"""
success_count = 0
errors = []
# Fetch nodes
data = self._fetch_json("/api/nodes")
if data is not None:
self._nodes = data if isinstance(data, list) else []
success_count += 1
logger.debug(f"Meshview: fetched {len(self._nodes)} nodes")
else:
errors.append("nodes")
# Fetch edges
data = self._fetch_json("/api/edges")
if data is not None:
self._edges = data if isinstance(data, list) else []
success_count += 1
logger.debug(f"Meshview: fetched {len(self._edges)} edges")
else:
errors.append("edges")
# Fetch stats (24h hourly)
data = self._fetch_json("/api/stats?period_type=hour&length=24")
if data is not None:
self._stats = data
success_count += 1
logger.debug("Meshview: fetched stats")
else:
errors.append("stats")
# Fetch counts
data = self._fetch_json("/api/stats/count")
if data is not None:
self._counts = data if isinstance(data, dict) else None
success_count += 1
logger.debug("Meshview: fetched counts")
else:
errors.append("counts")
# Update state
self._last_refresh = time.time()
if success_count > 0:
self._is_loaded = True
self._last_error = None
logger.info(
f"Meshview refresh: {len(self._nodes)} nodes, {len(self._edges)} edges"
)
return True
else:
self._last_error = f"All endpoints failed: {', '.join(errors)}"
logger.error(f"Meshview: {self._last_error}")
return False
def maybe_refresh(self) -> bool:
"""Refresh data if interval has elapsed.
Returns:
True if refresh was performed
"""
if time.time() - self._last_refresh >= self._refresh_interval:
return self.fetch_all()
return False