Initial commit: MeshAI - LLM-powered Meshtastic assistant

Features:
- Multi-backend LLM support (OpenAI, Anthropic, Google)
- Rolling summary memory for token optimization (~70-80% reduction)
- Per-user conversation history with SQLite persistence
- Bang commands (!help, !ping, !reset, !status, !weather)
- Meshtastic integration via serial or TCP
- Message chunking for mesh network constraints (150 char limit)
- Rate limiting to prevent network congestion
- Rich TUI configurator
- Docker support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2025-12-15 11:53:46 -07:00
commit fd3f995ebb
43 changed files with 7947 additions and 0 deletions

View file

@ -0,0 +1,6 @@
"""Bang commands for MeshAI."""
from .dispatcher import CommandDispatcher
from .base import CommandHandler, CommandContext
__all__ = ["CommandDispatcher", "CommandHandler", "CommandContext"]

72
meshai/commands/base.py Normal file
View file

@ -0,0 +1,72 @@
"""Base classes for command handlers."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ..config import Config
from ..connector import MeshConnector
from ..history import ConversationHistory
@dataclass
class CommandContext:
"""Context passed to command handlers."""
sender_id: str # Node ID of sender
sender_name: str # Display name of sender
channel: int # Channel message was received on
is_dm: bool # True if direct message
position: Optional[tuple[float, float]] # Sender's GPS position (lat, lon)
# References to shared resources
config: "Config"
connector: "MeshConnector"
history: "ConversationHistory"
class CommandHandler(ABC):
"""Base class for bang command handlers."""
# Command name (without !)
name: str = ""
# Brief description for !help
description: str = ""
# Usage example
usage: str = ""
@abstractmethod
async def execute(self, args: str, context: CommandContext) -> str:
"""Execute the command.
Args:
args: Arguments passed after the command (may be empty)
context: Command execution context
Returns:
Response string to send back
"""
pass
class CommandResult:
"""Result from command execution."""
def __init__(
self,
response: str,
success: bool = True,
suppress_history: bool = True,
):
"""
Args:
response: Text response to send
success: Whether command succeeded
suppress_history: If True, don't add to conversation history
"""
self.response = response
self.success = success
self.suppress_history = suppress_history

View file

@ -0,0 +1,116 @@
"""Command dispatcher for bang commands."""
import logging
from typing import Optional
from .base import CommandContext, CommandHandler
logger = logging.getLogger(__name__)
class CommandDispatcher:
"""Registry and dispatcher for bang commands."""
def __init__(self):
self._commands: dict[str, CommandHandler] = {}
def register(self, handler: CommandHandler) -> None:
"""Register a command handler.
Args:
handler: CommandHandler instance to register
"""
name = handler.name.upper()
self._commands[name] = handler
logger.debug(f"Registered command: !{handler.name}")
def get_commands(self) -> list[CommandHandler]:
"""Get all registered command handlers."""
return list(self._commands.values())
def is_command(self, text: str) -> bool:
"""Check if text is a bang command.
Args:
text: Message text to check
Returns:
True if text starts with !
"""
return text.strip().startswith("!")
def parse(self, text: str) -> tuple[Optional[str], str]:
"""Parse command and arguments from text.
Args:
text: Message text starting with !
Returns:
Tuple of (command_name, arguments) or (None, "") if invalid
"""
text = text.strip()
if not text.startswith("!"):
return None, ""
# Remove ! prefix
text = text[1:]
# Split into command and args
parts = text.split(maxsplit=1)
if not parts:
return None, ""
cmd = parts[0].upper()
args = parts[1] if len(parts) > 1 else ""
return cmd, args
async def dispatch(self, text: str, context: CommandContext) -> Optional[str]:
"""Dispatch a command and return response.
Args:
text: Message text (must start with !)
context: Command execution context
Returns:
Response string, or None if command not found
"""
cmd, args = self.parse(text)
if cmd is None:
return None
handler = self._commands.get(cmd)
if handler is None:
# Unknown command
return f"Unknown command: !{cmd.lower()}. Try !help"
try:
logger.debug(f"Dispatching !{cmd.lower()} from {context.sender_id}")
response = await handler.execute(args, context)
return response
except Exception as e:
logger.error(f"Error executing !{cmd.lower()}: {e}")
return f"Error: {str(e)[:100]}"
def create_dispatcher() -> CommandDispatcher:
"""Create and populate command dispatcher with default commands."""
from .help import HelpCommand
from .ping import PingCommand
from .reset import ResetCommand
from .status import StatusCommand
from .weather import WeatherCommand
dispatcher = CommandDispatcher()
# Register all commands
dispatcher.register(HelpCommand(dispatcher))
dispatcher.register(PingCommand())
dispatcher.register(ResetCommand())
dispatcher.register(StatusCommand())
dispatcher.register(WeatherCommand())
return dispatcher

25
meshai/commands/help.py Normal file
View file

@ -0,0 +1,25 @@
"""Help command handler."""
from .base import CommandContext, CommandHandler
class HelpCommand(CommandHandler):
"""Display available commands."""
name = "help"
description = "Show available commands"
usage = "!help"
def __init__(self, dispatcher):
self._dispatcher = dispatcher
async def execute(self, args: str, context: CommandContext) -> str:
"""List all available commands."""
commands = self._dispatcher.get_commands()
# Build compact help text
lines = ["Commands:"]
for cmd in sorted(commands, key=lambda c: c.name):
lines.append(f"!{cmd.name} - {cmd.description}")
return " | ".join(lines)

15
meshai/commands/ping.py Normal file
View file

@ -0,0 +1,15 @@
"""Ping command handler."""
from .base import CommandContext, CommandHandler
class PingCommand(CommandHandler):
"""Simple connectivity test."""
name = "ping"
description = "Test connectivity"
usage = "!ping"
async def execute(self, args: str, context: CommandContext) -> str:
"""Respond with pong."""
return "pong"

23
meshai/commands/reset.py Normal file
View file

@ -0,0 +1,23 @@
"""Reset command handler."""
from .base import CommandContext, CommandHandler
class ResetCommand(CommandHandler):
"""Clear conversation history and summary."""
name = "reset"
description = "Clear your chat history"
usage = "!reset"
async def execute(self, args: str, context: CommandContext) -> str:
"""Clear conversation history and summary for the sender."""
deleted = await context.history.clear_history(context.sender_id)
# Also clear the conversation summary
await context.history.clear_summary(context.sender_id)
if deleted > 0:
return f"Cleared {deleted} messages from history"
else:
return "No history to clear"

43
meshai/commands/status.py Normal file
View file

@ -0,0 +1,43 @@
"""Status command handler."""
import time
from datetime import timedelta
from .. import __version__
from .base import CommandContext, CommandHandler
# Track bot start time
_start_time: float = time.time()
def set_start_time(t: float) -> None:
"""Set bot start time (called from main)."""
global _start_time
_start_time = t
class StatusCommand(CommandHandler):
"""Show bot status information."""
name = "status"
description = "Show bot status"
usage = "!status"
async def execute(self, args: str, context: CommandContext) -> str:
"""Return bot status information."""
# Calculate uptime
uptime_seconds = int(time.time() - _start_time)
uptime = str(timedelta(seconds=uptime_seconds))
# Get history stats
stats = await context.history.get_stats()
# Build status message
parts = [
f"MeshAI v{__version__}",
f"Up: {uptime}",
f"Users: {stats['unique_users']}",
f"Msgs: {stats['total_messages']}",
]
return " | ".join(parts)

220
meshai/commands/weather.py Normal file
View file

@ -0,0 +1,220 @@
"""Weather command handler."""
import logging
from typing import Optional
import httpx
from .base import CommandContext, CommandHandler
logger = logging.getLogger(__name__)
class WeatherCommand(CommandHandler):
"""Get weather information."""
name = "weather"
description = "Get weather info"
usage = "!weather [location]"
async def execute(self, args: str, context: CommandContext) -> str:
"""Get weather for location or sender's GPS position."""
config = context.config.weather
# Determine location
location = await self._resolve_location(args.strip(), context)
if location is None:
return "No location available. Use !weather <city> or enable GPS on your node."
# Try primary provider
result = await self._fetch_weather(config.primary, location, context)
if result is None and config.fallback and config.fallback != "none":
# Try fallback
logger.debug(f"Primary weather provider failed, trying fallback: {config.fallback}")
result = await self._fetch_weather(config.fallback, location, context)
if result is None:
return "Weather lookup failed. Try again later."
return result
async def _resolve_location(
self, args: str, context: CommandContext
) -> Optional[str | tuple[float, float]]:
"""Resolve location from args, GPS, or config default.
Returns:
Location string, (lat, lon) tuple, or None
"""
# 1. If location provided in args, use it
if args:
return args
# 2. Try sender's GPS position
if context.position:
return context.position
# 3. Fall back to config default
default = context.config.weather.default_location
if default:
return default
return None
async def _fetch_weather(
self,
provider: str,
location: str | tuple[float, float],
context: CommandContext,
) -> Optional[str]:
"""Fetch weather from specified provider."""
try:
if provider == "openmeteo":
return await self._fetch_openmeteo(location, context)
elif provider == "wttr":
return await self._fetch_wttr(location, context)
elif provider == "llm":
return await self._fetch_llm(location, context)
else:
logger.warning(f"Unknown weather provider: {provider}")
return None
except Exception as e:
logger.error(f"Weather fetch error ({provider}): {e}")
return None
async def _fetch_openmeteo(
self,
location: str | tuple[float, float],
context: CommandContext,
) -> Optional[str]:
"""Fetch weather from Open-Meteo API."""
base_url = context.config.weather.openmeteo.url
# Get coordinates
if isinstance(location, tuple):
lat, lon = location
else:
# Geocode the location name
coords = await self._geocode(location)
if coords is None:
return None
lat, lon = coords
# Fetch current weather
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{base_url}/forecast",
params={
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,weathercode,windspeed_10m",
"temperature_unit": "fahrenheit",
"windspeed_unit": "mph",
},
)
response.raise_for_status()
data = response.json()
current = data.get("current", {})
temp = current.get("temperature_2m")
code = current.get("weathercode", 0)
wind = current.get("windspeed_10m")
if temp is None:
return None
# Convert weather code to description
condition = self._weather_code_to_text(code)
# Format location name
loc_name = location if isinstance(location, str) else f"{lat:.2f},{lon:.2f}"
return f"{loc_name}: {temp:.0f}F, {condition}, Wind {wind:.0f}mph"
async def _fetch_wttr(
self,
location: str | tuple[float, float],
context: CommandContext,
) -> Optional[str]:
"""Fetch weather from wttr.in."""
base_url = context.config.weather.wttr.url
# Format location for wttr.in
if isinstance(location, tuple):
lat, lon = location
loc_param = f"{lat},{lon}"
else:
loc_param = location.replace(" ", "+")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{base_url}/{loc_param}",
params={"format": "%l:+%t,+%C,+Wind+%w"},
headers={"User-Agent": "MeshAI/1.0"},
)
response.raise_for_status()
return response.text.strip()
async def _fetch_llm(
self,
location: str | tuple[float, float],
context: CommandContext,
) -> Optional[str]:
"""Let LLM fetch weather via web search.
This is a placeholder - actual implementation would route
to the LLM backend with a weather query.
"""
# For now, return None to indicate this provider isn't fully implemented
# The router will handle LLM queries separately
logger.debug("LLM weather provider not yet integrated")
return None
async def _geocode(self, location: str) -> Optional[tuple[float, float]]:
"""Geocode a location name to coordinates using Open-Meteo geocoding."""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": location, "count": 1},
)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
if not results:
return None
return (results[0]["latitude"], results[0]["longitude"])
def _weather_code_to_text(self, code: int) -> str:
"""Convert WMO weather code to text description."""
codes = {
0: "Clear",
1: "Mostly Clear",
2: "Partly Cloudy",
3: "Cloudy",
45: "Foggy",
48: "Fog",
51: "Light Drizzle",
53: "Drizzle",
55: "Heavy Drizzle",
61: "Light Rain",
63: "Rain",
65: "Heavy Rain",
71: "Light Snow",
73: "Snow",
75: "Heavy Snow",
77: "Snow Grains",
80: "Light Showers",
81: "Showers",
82: "Heavy Showers",
85: "Light Snow Showers",
86: "Snow Showers",
95: "Thunderstorm",
96: "Thunderstorm w/ Hail",
99: "Severe Thunderstorm",
}
return codes.get(code, "Unknown")