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

4
meshai/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""MeshAI - LLM-powered Meshtastic mesh network assistant."""
__version__ = "0.1.0"
__author__ = "K7ZVX"

6
meshai/__main__.py Normal file
View file

@ -0,0 +1,6 @@
"""Allow running as python -m meshai."""
from .main import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1,8 @@
"""LLM backends for MeshAI."""
from .base import LLMBackend
from .openai_backend import OpenAIBackend
from .anthropic_backend import AnthropicBackend
from .google_backend import GoogleBackend
__all__ = ["LLMBackend", "OpenAIBackend", "AnthropicBackend", "GoogleBackend"]

View file

@ -0,0 +1,205 @@
"""Anthropic (Claude) LLM backend with rolling summary memory."""
import logging
import time
from typing import Optional
from anthropic import AsyncAnthropic
from ..config import LLMConfig
from ..memory import ConversationSummary
from .base import LLMBackend
logger = logging.getLogger(__name__)
class AnthropicMemory:
"""Rolling summary memory for Anthropic backend."""
def __init__(self, client: AsyncAnthropic, model: str, window_size: int = 4, summarize_threshold: int = 8):
self._client = client
self._model = model
self._window_size = window_size
self._summarize_threshold = summarize_threshold
self._summaries: dict[str, ConversationSummary] = {}
async def get_context_messages(
self, user_id: str, full_history: list[dict]
) -> tuple[Optional[str], list[dict]]:
"""Get optimized context: summary + recent messages."""
if len(full_history) <= self._window_size * 2:
return None, full_history
split_point = -(self._window_size * 2)
old_messages = full_history[:split_point]
recent_messages = full_history[split_point:]
summary = await self._get_or_create_summary(user_id, old_messages)
return summary.summary, recent_messages
async def _get_or_create_summary(self, user_id: str, messages: list[dict]) -> ConversationSummary:
"""Get cached summary or create new one."""
if user_id in self._summaries:
cached = self._summaries[user_id]
if abs(cached.message_count - len(messages)) < self._summarize_threshold:
return cached
logger.debug(f"Generating summary for {user_id} ({len(messages)} messages)")
summary_text = await self._summarize(messages)
summary = ConversationSummary(
summary=summary_text,
last_updated=time.time(),
message_count=len(messages),
)
self._summaries[user_id] = summary
return summary
async def _summarize(self, messages: list[dict]) -> str:
"""Generate summary using Anthropic."""
if not messages:
return "No previous conversation."
conversation = "\n".join([f"{msg['role'].upper()}: {msg['content']}" for msg in messages])
prompt = f"""Summarize this conversation in 2-3 concise sentences. Focus on:
- Main topics discussed
- Important context or user preferences
- Key information to remember
Conversation:
{conversation}
Summary (2-3 sentences):"""
try:
response = await self._client.messages.create(
model=self._model,
max_tokens=150,
messages=[{"role": "user", "content": prompt}],
)
content = response.content[0].text if response.content else ""
return content.strip() if content else f"Previous conversation: {len(messages)} messages."
except Exception as e:
logger.warning(f"Failed to generate summary: {e}")
return f"Previous conversation: {len(messages)} messages about various topics."
def load_summary(self, user_id: str, summary: ConversationSummary) -> None:
"""Load summary from database into cache."""
self._summaries[user_id] = summary
def clear_summary(self, user_id: str) -> None:
"""Clear cached summary for user."""
self._summaries.pop(user_id, None)
def get_cached_summary(self, user_id: str) -> Optional[ConversationSummary]:
"""Get cached summary for user."""
return self._summaries.get(user_id)
class AnthropicBackend(LLMBackend):
"""Anthropic Claude backend with rolling summary memory."""
def __init__(
self,
config: LLMConfig,
api_key: str,
window_size: int = 4,
summarize_threshold: int = 8,
):
"""Initialize Anthropic backend.
Args:
config: LLM configuration
api_key: Anthropic API key
window_size: Recent message pairs to keep in full
summarize_threshold: Messages before re-summarizing
"""
self.config = config
self._client = AsyncAnthropic(api_key=api_key)
self._memory = AnthropicMemory(
client=self._client,
model=config.model,
window_size=window_size,
summarize_threshold=summarize_threshold,
)
async def generate(
self,
messages: list[dict],
system_prompt: str,
max_tokens: int = 300,
user_id: Optional[str] = None,
) -> str:
"""Generate a response using Anthropic API.
Args:
messages: Conversation history
system_prompt: System prompt
max_tokens: Maximum tokens to generate
user_id: User identifier (enables memory optimization)
Returns:
Generated response
"""
# Use memory manager to optimize context if user_id provided
if user_id and len(messages) > self._memory._window_size * 2:
summary, recent_messages = await self._memory.get_context_messages(
user_id=user_id,
full_history=messages,
)
if summary:
# Long conversation: system + summary + recent
enhanced_system = f"{system_prompt}\n\nPrevious conversation summary: {summary}"
final_messages = recent_messages
logger.debug(
f"Using summary + {len(recent_messages)} recent messages "
f"(total history: {len(messages)})"
)
else:
enhanced_system = system_prompt
final_messages = messages
else:
enhanced_system = system_prompt
final_messages = messages
try:
response = await self._client.messages.create(
model=self.config.model,
max_tokens=max_tokens,
system=enhanced_system,
messages=final_messages,
)
# Extract text from response
content = response.content[0].text if response.content else ""
return content.strip()
except Exception as e:
logger.error(f"Anthropic API error: {e}")
raise
def get_memory(self) -> AnthropicMemory:
"""Get the memory manager instance."""
return self._memory
async def generate_with_search(
self,
query: str,
system_prompt: Optional[str] = None,
) -> str:
"""Generate response - Anthropic doesn't have built-in search."""
prompt = system_prompt or (
"You are a helpful assistant. Answer the following question "
"based on your knowledge."
)
messages = [{"role": "user", "content": query}]
return await self.generate(messages, prompt, max_tokens=300)
async def close(self) -> None:
"""Close the client."""
await self._client.close()

57
meshai/backends/base.py Normal file
View file

@ -0,0 +1,57 @@
"""Base class for LLM backends."""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ..memory import ConversationSummary
class LLMBackend(ABC):
"""Abstract base class for LLM backends."""
@abstractmethod
async def generate(
self,
messages: list[dict],
system_prompt: str,
max_tokens: int = 300,
user_id: Optional[str] = None,
) -> str:
"""Generate a response from the LLM.
Args:
messages: Conversation history as list of {"role": str, "content": str}
system_prompt: System prompt to use
max_tokens: Maximum tokens in response
user_id: User identifier for memory optimization (optional)
Returns:
Generated response text
"""
pass
def get_memory(self):
"""Get the memory manager instance. Override in subclasses."""
return None
@abstractmethod
async def generate_with_search(
self,
query: str,
system_prompt: Optional[str] = None,
) -> str:
"""Generate a response with web search capability.
Args:
query: Search/question to answer
system_prompt: Optional system prompt
Returns:
Generated response text
"""
pass
async def close(self) -> None:
"""Clean up resources. Override if needed."""
pass

View file

@ -0,0 +1,215 @@
"""Google Gemini LLM backend with rolling summary memory."""
import logging
import time
from typing import Optional
import google.generativeai as genai
from ..config import LLMConfig
from ..memory import ConversationSummary
from .base import LLMBackend
logger = logging.getLogger(__name__)
class GoogleMemory:
"""Rolling summary memory for Google backend."""
def __init__(self, model: genai.GenerativeModel, window_size: int = 4, summarize_threshold: int = 8):
self._model = model
self._window_size = window_size
self._summarize_threshold = summarize_threshold
self._summaries: dict[str, ConversationSummary] = {}
async def get_context_messages(
self, user_id: str, full_history: list[dict]
) -> tuple[Optional[str], list[dict]]:
"""Get optimized context: summary + recent messages."""
if len(full_history) <= self._window_size * 2:
return None, full_history
split_point = -(self._window_size * 2)
old_messages = full_history[:split_point]
recent_messages = full_history[split_point:]
summary = await self._get_or_create_summary(user_id, old_messages)
return summary.summary, recent_messages
async def _get_or_create_summary(self, user_id: str, messages: list[dict]) -> ConversationSummary:
"""Get cached summary or create new one."""
if user_id in self._summaries:
cached = self._summaries[user_id]
if abs(cached.message_count - len(messages)) < self._summarize_threshold:
return cached
logger.debug(f"Generating summary for {user_id} ({len(messages)} messages)")
summary_text = await self._summarize(messages)
summary = ConversationSummary(
summary=summary_text,
last_updated=time.time(),
message_count=len(messages),
)
self._summaries[user_id] = summary
return summary
async def _summarize(self, messages: list[dict]) -> str:
"""Generate summary using Google Gemini."""
if not messages:
return "No previous conversation."
conversation = "\n".join([f"{msg['role'].upper()}: {msg['content']}" for msg in messages])
prompt = f"""Summarize this conversation in 2-3 concise sentences. Focus on:
- Main topics discussed
- Important context or user preferences
- Key information to remember
Conversation:
{conversation}
Summary (2-3 sentences):"""
try:
response = await self._model.generate_content_async(
prompt,
generation_config=genai.types.GenerationConfig(
max_output_tokens=150,
temperature=0.3,
),
)
return response.text.strip() if response.text else f"Previous conversation: {len(messages)} messages."
except Exception as e:
logger.warning(f"Failed to generate summary: {e}")
return f"Previous conversation: {len(messages)} messages about various topics."
def load_summary(self, user_id: str, summary: ConversationSummary) -> None:
"""Load summary from database into cache."""
self._summaries[user_id] = summary
def clear_summary(self, user_id: str) -> None:
"""Clear cached summary for user."""
self._summaries.pop(user_id, None)
def get_cached_summary(self, user_id: str) -> Optional[ConversationSummary]:
"""Get cached summary for user."""
return self._summaries.get(user_id)
class GoogleBackend(LLMBackend):
"""Google Gemini backend with rolling summary memory."""
def __init__(
self,
config: LLMConfig,
api_key: str,
window_size: int = 4,
summarize_threshold: int = 8,
):
"""Initialize Google backend.
Args:
config: LLM configuration
api_key: Google API key
window_size: Recent message pairs to keep in full
summarize_threshold: Messages before re-summarizing
"""
self.config = config
genai.configure(api_key=api_key)
self._model = genai.GenerativeModel(config.model)
self._memory = GoogleMemory(
model=self._model,
window_size=window_size,
summarize_threshold=summarize_threshold,
)
async def generate(
self,
messages: list[dict],
system_prompt: str,
max_tokens: int = 300,
user_id: Optional[str] = None,
) -> str:
"""Generate a response using Google Gemini API.
Args:
messages: Conversation history
system_prompt: System prompt
max_tokens: Maximum tokens to generate
user_id: User identifier (enables memory optimization)
Returns:
Generated response
"""
# Use memory manager to optimize context if user_id provided
enhanced_system = system_prompt
final_messages = messages
if user_id and len(messages) > self._memory._window_size * 2:
summary, recent_messages = await self._memory.get_context_messages(
user_id=user_id,
full_history=messages,
)
if summary:
enhanced_system = f"{system_prompt}\n\nPrevious conversation summary: {summary}"
final_messages = recent_messages
logger.debug(
f"Using summary + {len(recent_messages)} recent messages "
f"(total history: {len(messages)})"
)
try:
# Convert messages to Gemini format
# Gemini uses "user" and "model" roles
history = []
for msg in final_messages[:-1]: # All but last message
role = "model" if msg["role"] == "assistant" else "user"
history.append({"role": role, "parts": [msg["content"]]})
# Start chat with history
chat = self._model.start_chat(history=history)
# Get the last user message
last_message = final_messages[-1]["content"] if final_messages else ""
# Prepend system prompt to first message if needed
if enhanced_system and not history:
last_message = f"{enhanced_system}\n\n{last_message}"
# Generate response
response = await chat.send_message_async(
last_message,
generation_config=genai.types.GenerationConfig(
max_output_tokens=max_tokens,
temperature=0.7,
),
)
return response.text.strip() if response.text else ""
except Exception as e:
logger.error(f"Google API error: {e}")
raise
def get_memory(self) -> GoogleMemory:
"""Get the memory manager instance."""
return self._memory
async def generate_with_search(
self,
query: str,
system_prompt: Optional[str] = None,
) -> str:
"""Generate response - uses Gemini's built-in grounding if available."""
prompt = system_prompt or "You are a helpful assistant."
messages = [{"role": "user", "content": query}]
return await self.generate(messages, prompt, max_tokens=300)
async def close(self) -> None:
"""Clean up - nothing to close for Google client."""
pass

View file

@ -0,0 +1,132 @@
"""OpenAI-compatible LLM backend with rolling summary memory."""
import logging
from typing import Optional
from openai import AsyncOpenAI
from ..config import LLMConfig
from ..memory import ConversationSummary, RollingSummaryMemory
from .base import LLMBackend
logger = logging.getLogger(__name__)
class OpenAIBackend(LLMBackend):
"""OpenAI-compatible backend (works with OpenAI, LiteLLM, local models)."""
def __init__(
self,
config: LLMConfig,
api_key: str,
window_size: int = 4,
summarize_threshold: int = 8,
):
"""Initialize OpenAI backend.
Args:
config: LLM configuration
api_key: API key to use
window_size: Recent message pairs to keep in full
summarize_threshold: Messages before re-summarizing
"""
self.config = config
self._client = AsyncOpenAI(
api_key=api_key,
base_url=config.base_url,
)
# Initialize rolling summary memory for context optimization
self._memory = RollingSummaryMemory(
client=self._client,
model=config.model,
window_size=window_size,
summarize_threshold=summarize_threshold,
)
async def generate(
self,
messages: list[dict],
system_prompt: str,
max_tokens: int = 300,
user_id: Optional[str] = None,
) -> str:
"""Generate a response using OpenAI-compatible API.
Args:
messages: Conversation history
system_prompt: System prompt
max_tokens: Maximum tokens to generate
user_id: User identifier (enables memory optimization)
Returns:
Generated response
"""
# Use memory manager to optimize context if user_id provided
if user_id and len(messages) > self._memory._window_size * 2:
summary, recent_messages = await self._memory.get_context_messages(
user_id=user_id,
full_history=messages,
)
if summary:
# Long conversation: system + summary + recent
enhanced_system = f"{system_prompt}\n\nPrevious conversation summary: {summary}"
full_messages = [{"role": "system", "content": enhanced_system}]
full_messages.extend(recent_messages)
logger.debug(
f"Using summary + {len(recent_messages)} recent messages "
f"(total history: {len(messages)})"
)
else:
# Short conversation: system + all messages
full_messages = [{"role": "system", "content": system_prompt}]
full_messages.extend(messages)
else:
# No user_id or short conversation - use full history
full_messages = [{"role": "system", "content": system_prompt}]
full_messages.extend(messages)
try:
response = await self._client.chat.completions.create(
model=self.config.model,
messages=full_messages,
max_tokens=max_tokens,
temperature=0.7,
)
content = response.choices[0].message.content
return content.strip() if content else ""
except Exception as e:
logger.error(f"OpenAI API error: {e}")
raise
def get_memory(self) -> RollingSummaryMemory:
"""Get the memory manager instance."""
return self._memory
async def generate_with_search(
self,
query: str,
system_prompt: Optional[str] = None,
) -> str:
"""Generate response - search depends on model/provider capabilities.
Note: True web search requires the model/provider to support it
(e.g., OpenAI with plugins, or a local setup with SearXNG).
This implementation just passes the query as a regular message.
"""
prompt = system_prompt or (
"You are a helpful assistant. Answer the following question. "
"If you have web search access, use it for current information."
)
messages = [{"role": "user", "content": query}]
return await self.generate(messages, prompt, max_tokens=300)
async def close(self) -> None:
"""Close the client."""
await self._client.close()

5
meshai/cli/__init__.py Normal file
View file

@ -0,0 +1,5 @@
"""CLI tools for MeshAI."""
from .configurator import run_configurator
__all__ = ["run_configurator"]

612
meshai/cli/configurator.py Normal file
View file

@ -0,0 +1,612 @@
"""Rich-based TUI configurator for MeshAI."""
import os
import signal
import subprocess
import sys
from pathlib import Path
from typing import Optional
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Confirm, IntPrompt, Prompt
from rich.table import Table
from rich.text import Text
from ..config import Config, get_default_config, load_config, save_config
console = Console()
class Configurator:
"""Interactive configuration tool for MeshAI."""
def __init__(self, config_path: Optional[Path] = None):
self.config_path = config_path or Path("config.yaml")
self.config: Config = load_config(self.config_path)
self.modified = False
def run(self) -> None:
"""Run the configurator."""
try:
self._show_welcome()
self._main_menu()
except KeyboardInterrupt:
self._handle_exit()
def _clear(self) -> None:
"""Clear the screen."""
console.clear()
def _show_welcome(self) -> None:
"""Display welcome header."""
self._clear()
header = Panel(
Text(
"MeshAI Configuration Tool\n"
"Configure your Meshtastic LLM assistant",
justify="center",
style="cyan",
),
title="[yellow]Welcome[/yellow]",
border_style="blue",
)
console.print(header)
console.print()
def _status_icon(self, value: bool) -> str:
"""Return colored status icon."""
return "[green]✓[/green]" if value else "[red]✗[/red]"
def _main_menu(self) -> None:
"""Display and handle main menu."""
while True:
self._clear()
self._show_header()
table = Table(box=box.ROUNDED, show_header=False)
table.add_column("Option", style="cyan", width=4)
table.add_column("Description", style="white")
table.add_column("Status", style="dim")
table.add_row("1", "Bot Settings", f"@{self.config.bot.name}")
table.add_row("2", "Connection", f"{self.config.connection.type}")
table.add_row("3", "LLM Backend", f"{self.config.llm.backend}")
table.add_row("4", "Weather", f"{self.config.weather.primary}")
table.add_row("5", "Response Settings", f"{self.config.response.max_length}ch")
table.add_row("6", "Channel Filtering", f"{self.config.channels.mode}")
table.add_row("7", "History Settings", f"{self.config.history.max_messages_per_user} msgs")
table.add_row("8", "Run Setup Wizard", "[dim]First-time setup[/dim]")
table.add_row("0", "Save & Exit", self._get_modified_indicator())
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
self._handle_exit()
break
elif choice == 1:
self._bot_settings()
elif choice == 2:
self._connection_settings()
elif choice == 3:
self._llm_settings()
elif choice == 4:
self._weather_settings()
elif choice == 5:
self._response_settings()
elif choice == 6:
self._channel_settings()
elif choice == 7:
self._history_settings()
elif choice == 8:
self._setup_wizard()
def _show_header(self) -> None:
"""Show compact header with modified indicator."""
title = "[bold cyan]MeshAI Configuration[/bold cyan]"
if self.modified:
title += " [yellow]*[/yellow]"
console.print(Panel(title, box=box.MINIMAL))
def _get_modified_indicator(self) -> str:
"""Return modified indicator string."""
return "[yellow]* Unsaved changes[/yellow]" if self.modified else ""
def _bot_settings(self) -> None:
"""Bot settings submenu."""
while True:
self._clear()
console.print("[bold]Bot Settings[/bold]\n")
table = Table(box=box.ROUNDED)
table.add_column("Option", style="cyan", width=4)
table.add_column("Setting", style="white")
table.add_column("Value", style="green")
table.add_row("1", "Bot Name (@mention)", self.config.bot.name)
table.add_row("2", "Owner", self.config.bot.owner or "[dim]not set[/dim]")
table.add_row(
"3",
"Respond to @mentions",
self._status_icon(self.config.bot.respond_to_mentions),
)
table.add_row(
"4", "Respond to DMs", self._status_icon(self.config.bot.respond_to_dms)
)
table.add_row("0", "Back", "")
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
value = Prompt.ask("Bot name", default=self.config.bot.name)
if value != self.config.bot.name:
self.config.bot.name = value
self.modified = True
elif choice == 2:
value = Prompt.ask("Owner", default=self.config.bot.owner)
if value != self.config.bot.owner:
self.config.bot.owner = value
self.modified = True
elif choice == 3:
value = Confirm.ask(
"Respond to @mentions?", default=self.config.bot.respond_to_mentions
)
if value != self.config.bot.respond_to_mentions:
self.config.bot.respond_to_mentions = value
self.modified = True
elif choice == 4:
value = Confirm.ask("Respond to DMs?", default=self.config.bot.respond_to_dms)
if value != self.config.bot.respond_to_dms:
self.config.bot.respond_to_dms = value
self.modified = True
def _connection_settings(self) -> None:
"""Connection settings submenu."""
while True:
self._clear()
console.print("[bold]Connection Settings[/bold]\n")
table = Table(box=box.ROUNDED)
table.add_column("Option", style="cyan", width=4)
table.add_column("Setting", style="white")
table.add_column("Value", style="green")
table.add_row("1", "Connection Type", self.config.connection.type)
table.add_row("2", "Serial Port", self.config.connection.serial_port)
table.add_row("3", "TCP Host", self.config.connection.tcp_host)
table.add_row("4", "TCP Port", str(self.config.connection.tcp_port))
table.add_row("0", "Back", "")
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
console.print("\n[cyan]1.[/cyan] serial - USB Serial connection")
console.print("[cyan]2.[/cyan] tcp - TCP Network connection")
sel = IntPrompt.ask("Select", default=1 if self.config.connection.type == "serial" else 2)
value = "serial" if sel == 1 else "tcp"
if value != self.config.connection.type:
self.config.connection.type = value
self.modified = True
elif choice == 2:
value = Prompt.ask("Serial port", default=self.config.connection.serial_port)
if value != self.config.connection.serial_port:
self.config.connection.serial_port = value
self.modified = True
elif choice == 3:
value = Prompt.ask("TCP host", default=self.config.connection.tcp_host)
if value != self.config.connection.tcp_host:
self.config.connection.tcp_host = value
self.modified = True
elif choice == 4:
value = IntPrompt.ask("TCP port", default=self.config.connection.tcp_port)
if value != self.config.connection.tcp_port:
self.config.connection.tcp_port = value
self.modified = True
def _llm_settings(self) -> None:
"""LLM backend settings submenu."""
while True:
self._clear()
console.print("[bold]LLM Backend Settings[/bold]\n")
# Mask API key for display
api_key_display = "****" + self.config.llm.api_key[-4:] if len(self.config.llm.api_key) > 4 else "[dim]not set[/dim]"
table = Table(box=box.ROUNDED)
table.add_column("Option", style="cyan", width=4)
table.add_column("Setting", style="white")
table.add_column("Value", style="green")
table.add_row("1", "Backend", self.config.llm.backend)
table.add_row("2", "API Key", api_key_display)
table.add_row("3", "Base URL", self.config.llm.base_url)
table.add_row("4", "Model", self.config.llm.model)
table.add_row("5", "System Prompt", f"[dim]{len(self.config.llm.system_prompt)} chars[/dim]")
table.add_row("0", "Back", "")
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
console.print("\n[cyan]1.[/cyan] openai - OpenAI / OpenAI-compatible (LiteLLM, etc)")
console.print("[cyan]2.[/cyan] anthropic - Anthropic Claude")
console.print("[cyan]3.[/cyan] google - Google Gemini")
sel = IntPrompt.ask("Select", default=1)
backends = {1: "openai", 2: "anthropic", 3: "google"}
value = backends.get(sel, "openai")
if value != self.config.llm.backend:
self.config.llm.backend = value
self.modified = True
elif choice == 2:
value = Prompt.ask("API Key", password=True)
if value:
self.config.llm.api_key = value
self.modified = True
elif choice == 3:
value = Prompt.ask("Base URL", default=self.config.llm.base_url)
if value != self.config.llm.base_url:
self.config.llm.base_url = value
self.modified = True
elif choice == 4:
value = Prompt.ask("Model", default=self.config.llm.model)
if value != self.config.llm.model:
self.config.llm.model = value
self.modified = True
elif choice == 5:
console.print("\n[dim]Current prompt:[/dim]")
console.print(self.config.llm.system_prompt)
console.print()
if Confirm.ask("Edit system prompt?", default=False):
value = Prompt.ask("New system prompt")
if value:
self.config.llm.system_prompt = value
self.modified = True
def _weather_settings(self) -> None:
"""Weather settings submenu."""
while True:
self._clear()
console.print("[bold]Weather Settings[/bold]\n")
table = Table(box=box.ROUNDED)
table.add_column("Option", style="cyan", width=4)
table.add_column("Setting", style="white")
table.add_column("Value", style="green")
table.add_row("1", "Primary Provider", self.config.weather.primary)
table.add_row("2", "Fallback Provider", self.config.weather.fallback)
table.add_row("3", "Default Location", self.config.weather.default_location or "[dim]not set[/dim]")
table.add_row("4", "Open-Meteo URL", self.config.weather.openmeteo.url)
table.add_row("5", "wttr.in URL", self.config.weather.wttr.url)
table.add_row("0", "Back", "")
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
console.print("\n[cyan]1.[/cyan] openmeteo - Open-Meteo API (free, no key)")
console.print("[cyan]2.[/cyan] wttr - wttr.in (free, simple)")
console.print("[cyan]3.[/cyan] llm - Use LLM with web search")
sel = IntPrompt.ask("Select", default=1)
providers = {1: "openmeteo", 2: "wttr", 3: "llm"}
value = providers.get(sel, "openmeteo")
if value != self.config.weather.primary:
self.config.weather.primary = value
self.modified = True
elif choice == 2:
console.print("\n[cyan]1.[/cyan] openmeteo")
console.print("[cyan]2.[/cyan] wttr")
console.print("[cyan]3.[/cyan] llm")
console.print("[cyan]4.[/cyan] none - No fallback")
sel = IntPrompt.ask("Select", default=3)
providers = {1: "openmeteo", 2: "wttr", 3: "llm", 4: "none"}
value = providers.get(sel, "llm")
if value != self.config.weather.fallback:
self.config.weather.fallback = value
self.modified = True
elif choice == 3:
value = Prompt.ask("Default location", default=self.config.weather.default_location)
if value != self.config.weather.default_location:
self.config.weather.default_location = value
self.modified = True
elif choice == 4:
value = Prompt.ask("Open-Meteo URL", default=self.config.weather.openmeteo.url)
if value != self.config.weather.openmeteo.url:
self.config.weather.openmeteo.url = value
self.modified = True
elif choice == 5:
value = Prompt.ask("wttr.in URL", default=self.config.weather.wttr.url)
if value != self.config.weather.wttr.url:
self.config.weather.wttr.url = value
self.modified = True
def _response_settings(self) -> None:
"""Response settings submenu."""
while True:
self._clear()
console.print("[bold]Response Settings[/bold]\n")
table = Table(box=box.ROUNDED)
table.add_column("Option", style="cyan", width=4)
table.add_column("Setting", style="white")
table.add_column("Value", style="green")
table.add_row("1", "Min Delay (seconds)", str(self.config.response.delay_min))
table.add_row("2", "Max Delay (seconds)", str(self.config.response.delay_max))
table.add_row("3", "Max Length (chars)", str(self.config.response.max_length))
table.add_row("4", "Max Messages", str(self.config.response.max_messages))
table.add_row("0", "Back", "")
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
value = float(Prompt.ask("Min delay", default=str(self.config.response.delay_min)))
if value != self.config.response.delay_min:
self.config.response.delay_min = value
self.modified = True
elif choice == 2:
value = float(Prompt.ask("Max delay", default=str(self.config.response.delay_max)))
if value != self.config.response.delay_max:
self.config.response.delay_max = value
self.modified = True
elif choice == 3:
value = IntPrompt.ask("Max length", default=self.config.response.max_length)
if value != self.config.response.max_length:
self.config.response.max_length = value
self.modified = True
elif choice == 4:
value = IntPrompt.ask("Max messages", default=self.config.response.max_messages)
if value != self.config.response.max_messages:
self.config.response.max_messages = value
self.modified = True
def _channel_settings(self) -> None:
"""Channel filtering settings submenu."""
while True:
self._clear()
console.print("[bold]Channel Filtering[/bold]\n")
table = Table(box=box.ROUNDED)
table.add_column("Option", style="cyan", width=4)
table.add_column("Setting", style="white")
table.add_column("Value", style="green")
whitelist_str = ", ".join(str(c) for c in self.config.channels.whitelist)
table.add_row("1", "Mode", self.config.channels.mode)
table.add_row("2", "Whitelist Channels", whitelist_str or "[dim]none[/dim]")
table.add_row("0", "Back", "")
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
console.print("\n[cyan]1.[/cyan] all - Respond on all channels")
console.print("[cyan]2.[/cyan] whitelist - Only respond on specific channels")
sel = IntPrompt.ask("Select", default=1 if self.config.channels.mode == "all" else 2)
value = "all" if sel == 1 else "whitelist"
if value != self.config.channels.mode:
self.config.channels.mode = value
self.modified = True
elif choice == 2:
value = Prompt.ask(
"Whitelist (comma-separated)", default=whitelist_str
)
try:
channels = [int(c.strip()) for c in value.split(",") if c.strip()]
if channels != self.config.channels.whitelist:
self.config.channels.whitelist = channels
self.modified = True
except ValueError:
console.print("[red]Invalid input. Use comma-separated numbers.[/red]")
def _history_settings(self) -> None:
"""History settings submenu."""
while True:
self._clear()
console.print("[bold]History Settings[/bold]\n")
table = Table(box=box.ROUNDED)
table.add_column("Option", style="cyan", width=4)
table.add_column("Setting", style="white")
table.add_column("Value", style="green")
timeout_hours = self.config.history.conversation_timeout // 3600
table.add_row("1", "Database File", self.config.history.database)
table.add_row("2", "Max Messages Per User", str(self.config.history.max_messages_per_user))
table.add_row("3", "Conversation Timeout", f"{timeout_hours}h")
table.add_row("0", "Back", "")
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
value = Prompt.ask("Database file", default=self.config.history.database)
if value != self.config.history.database:
self.config.history.database = value
self.modified = True
elif choice == 2:
value = IntPrompt.ask(
"Max messages per user", default=self.config.history.max_messages_per_user
)
if value != self.config.history.max_messages_per_user:
self.config.history.max_messages_per_user = value
self.modified = True
elif choice == 3:
value = IntPrompt.ask("Timeout (hours)", default=timeout_hours)
seconds = value * 3600
if seconds != self.config.history.conversation_timeout:
self.config.history.conversation_timeout = seconds
self.modified = True
def _setup_wizard(self) -> None:
"""First-time setup wizard."""
self._clear()
console.print(Panel("[bold]MeshAI Setup Wizard[/bold]", style="cyan"))
console.print("\nThis wizard will help you configure MeshAI.\n")
# Step 1: Bot identity
console.print("[bold cyan]Step 1: Bot Identity[/bold cyan]")
self.config.bot.name = Prompt.ask("Bot name (for @mentions)", default="ai")
self.config.bot.owner = Prompt.ask("Your name/callsign", default="")
console.print()
# Step 2: Connection
console.print("[bold cyan]Step 2: Meshtastic Connection[/bold cyan]")
console.print("[cyan]1.[/cyan] serial - USB Serial")
console.print("[cyan]2.[/cyan] tcp - Network TCP")
sel = IntPrompt.ask("Connection type", default=1)
self.config.connection.type = "serial" if sel == 1 else "tcp"
if self.config.connection.type == "serial":
self.config.connection.serial_port = Prompt.ask(
"Serial port", default="/dev/ttyUSB0"
)
else:
self.config.connection.tcp_host = Prompt.ask(
"TCP host", default="192.168.1.100"
)
self.config.connection.tcp_port = IntPrompt.ask("TCP port", default=4403)
console.print()
# Step 3: LLM
console.print("[bold cyan]Step 3: LLM Backend[/bold cyan]")
console.print("[cyan]1.[/cyan] openai - OpenAI / OpenAI-compatible")
console.print("[cyan]2.[/cyan] anthropic - Anthropic Claude")
console.print("[cyan]3.[/cyan] google - Google Gemini")
sel = IntPrompt.ask("Backend", default=1)
backends = {1: "openai", 2: "anthropic", 3: "google"}
self.config.llm.backend = backends.get(sel, "openai")
self.config.llm.api_key = Prompt.ask("API Key", password=True)
if self.config.llm.backend == "openai":
if Confirm.ask("Using local/self-hosted API?", default=False):
self.config.llm.base_url = Prompt.ask(
"Base URL", default="http://localhost:4000/v1"
)
self.config.llm.model = Prompt.ask("Model", default="gpt-4o-mini")
console.print()
# Step 4: Weather (optional)
console.print("[bold cyan]Step 4: Weather (optional)[/bold cyan]")
self.config.weather.default_location = Prompt.ask(
"Default location (for !weather)", default=""
)
console.print()
self.modified = True
console.print("[green]Setup complete![/green]")
console.print("Press Enter to return to main menu...")
input()
def _handle_exit(self) -> None:
"""Handle exit with save prompt."""
if self.modified:
if Confirm.ask("\n[yellow]Save changes before exit?[/yellow]", default=True):
self._save_and_restart()
console.print("\nGoodbye!")
def _save_and_restart(self) -> None:
"""Save config and optionally restart the bot."""
save_config(self.config, self.config_path)
console.print(f"[green]Configuration saved to {self.config_path}[/green]")
self.modified = False
# Check if bot is running and offer restart
if self._is_bot_running():
if Confirm.ask("Restart bot with new config?", default=True):
self._restart_bot()
def _is_bot_running(self) -> bool:
"""Check if meshai bot is running."""
pid_file = Path("/tmp/meshai.pid")
if pid_file.exists():
try:
pid = int(pid_file.read_text().strip())
os.kill(pid, 0) # Check if process exists
return True
except (ValueError, OSError):
pass
# Also check systemd
try:
result = subprocess.run(
["systemctl", "is-active", "meshai"],
capture_output=True,
text=True,
)
return result.stdout.strip() == "active"
except FileNotFoundError:
pass
return False
def _restart_bot(self) -> None:
"""Restart the bot."""
# Try systemd first
try:
result = subprocess.run(
["systemctl", "restart", "meshai"],
capture_output=True,
text=True,
)
if result.returncode == 0:
console.print("[green]Bot restarted via systemd[/green]")
return
except FileNotFoundError:
pass
# Try SIGHUP to running process
pid_file = Path("/tmp/meshai.pid")
if pid_file.exists():
try:
pid = int(pid_file.read_text().strip())
os.kill(pid, signal.SIGHUP)
console.print("[green]Sent reload signal to bot[/green]")
return
except (ValueError, OSError) as e:
console.print(f"[yellow]Could not signal bot: {e}[/yellow]")
console.print("[yellow]Could not restart bot automatically. Please restart manually.[/yellow]")
def run_configurator(config_path: Optional[Path] = None) -> None:
"""Entry point for configurator."""
configurator = Configurator(config_path)
configurator.run()

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")

233
meshai/config.py Normal file
View file

@ -0,0 +1,233 @@
"""Configuration management for MeshAI."""
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
@dataclass
class BotConfig:
"""Bot identity and trigger settings."""
name: str = "ai"
owner: str = ""
respond_to_mentions: bool = True
respond_to_dms: bool = True
@dataclass
class ConnectionConfig:
"""Meshtastic connection settings."""
type: str = "serial" # serial or tcp
serial_port: str = "/dev/ttyUSB0"
tcp_host: str = "192.168.1.100"
tcp_port: int = 4403
@dataclass
class ChannelsConfig:
"""Channel filtering settings."""
mode: str = "all" # all or whitelist
whitelist: list[int] = field(default_factory=lambda: [0])
@dataclass
class ResponseConfig:
"""Response behavior settings."""
delay_min: float = 2.2
delay_max: float = 3.0
max_length: int = 150
max_messages: int = 2
@dataclass
class HistoryConfig:
"""Conversation history settings."""
database: str = "conversations.db"
max_messages_per_user: int = 20
conversation_timeout: int = 86400 # 24 hours
@dataclass
class MemoryConfig:
"""Rolling summary memory settings."""
enabled: bool = True # Enable memory optimization
window_size: int = 4 # Recent message pairs to keep in full
summarize_threshold: int = 8 # Messages before re-summarizing
@dataclass
class LLMConfig:
"""LLM backend settings."""
backend: str = "openai" # openai, anthropic, google
api_key: str = ""
base_url: str = "https://api.openai.com/v1"
model: str = "gpt-4o-mini"
system_prompt: str = (
"You are a helpful assistant on a Meshtastic mesh network. "
"Keep responses VERY brief - under 250 characters total. "
"Be concise but friendly. No markdown formatting."
)
@dataclass
class OpenMeteoConfig:
"""Open-Meteo weather provider settings."""
url: str = "https://api.open-meteo.com/v1"
@dataclass
class WttrConfig:
"""wttr.in weather provider settings."""
url: str = "https://wttr.in"
@dataclass
class WeatherConfig:
"""Weather command settings."""
primary: str = "openmeteo" # openmeteo, wttr, llm
fallback: str = "llm" # openmeteo, wttr, llm, none
default_location: str = ""
openmeteo: OpenMeteoConfig = field(default_factory=OpenMeteoConfig)
wttr: WttrConfig = field(default_factory=WttrConfig)
@dataclass
class Config:
"""Main configuration container."""
bot: BotConfig = field(default_factory=BotConfig)
connection: ConnectionConfig = field(default_factory=ConnectionConfig)
channels: ChannelsConfig = field(default_factory=ChannelsConfig)
response: ResponseConfig = field(default_factory=ResponseConfig)
history: HistoryConfig = field(default_factory=HistoryConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig)
llm: LLMConfig = field(default_factory=LLMConfig)
weather: WeatherConfig = field(default_factory=WeatherConfig)
_config_path: Optional[Path] = field(default=None, repr=False)
def resolve_api_key(self) -> str:
"""Resolve API key from config or environment."""
if self.llm.api_key:
# Check if it's an env var reference like ${LLM_API_KEY}
if self.llm.api_key.startswith("${") and self.llm.api_key.endswith("}"):
env_var = self.llm.api_key[2:-1]
return os.environ.get(env_var, "")
return self.llm.api_key
# Fall back to common env vars
for env_var in ["LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
if value := os.environ.get(env_var):
return value
return ""
def _dict_to_dataclass(cls, data: dict):
"""Recursively convert dict to dataclass, handling nested structures."""
if data is None:
return cls()
field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
kwargs = {}
for key, value in data.items():
if key.startswith("_"):
continue
if key not in field_types:
continue
field_type = field_types[key]
# Handle nested dataclasses
if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(field_type, value)
else:
kwargs[key] = value
return cls(**kwargs)
def _dataclass_to_dict(obj) -> dict:
"""Recursively convert dataclass to dict for YAML serialization."""
if not hasattr(obj, "__dataclass_fields__"):
return obj
result = {}
for field_name in obj.__dataclass_fields__:
if field_name.startswith("_"):
continue
value = getattr(obj, field_name)
if hasattr(value, "__dataclass_fields__"):
result[field_name] = _dataclass_to_dict(value)
elif isinstance(value, list):
result[field_name] = list(value)
else:
result[field_name] = value
return result
def load_config(config_path: Optional[Path] = None) -> Config:
"""Load configuration from YAML file.
Args:
config_path: Path to config file. Defaults to ./config.yaml
Returns:
Config object with loaded settings
"""
if config_path is None:
config_path = Path("config.yaml")
config_path = Path(config_path)
if not config_path.exists():
# Return default config if file doesn't exist
config = Config()
config._config_path = config_path
return config
with open(config_path, "r") as f:
data = yaml.safe_load(f) or {}
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)
def get_default_config() -> Config:
"""Get a Config object with all default values."""
return Config()

273
meshai/connector.py Normal file
View file

@ -0,0 +1,273 @@
"""Meshtastic connection management for MeshAI."""
import asyncio
import logging
from dataclasses import dataclass
from typing import Callable, Optional
import meshtastic
import meshtastic.serial_interface
import meshtastic.tcp_interface
from meshtastic import BROADCAST_NUM
from pubsub import pub
from .config import ConnectionConfig
logger = logging.getLogger(__name__)
@dataclass
class MeshMessage:
"""Represents an incoming mesh message."""
sender_id: str # Node ID (hex string like "!abcd1234")
sender_name: str # Short name or long name
text: str # Message content
channel: int # Channel index
is_dm: bool # True if direct message to us
packet: dict # Raw packet for additional data
@property
def sender_position(self) -> Optional[tuple[float, float]]:
"""Get sender's GPS position if available (lat, lon)."""
# Position comes from node info, not the message itself
# This will be populated by the connector if available
return self._position if hasattr(self, "_position") else None
class MeshConnector:
"""Manages connection to Meshtastic node."""
def __init__(self, config: ConnectionConfig):
self.config = config
self._interface: Optional[meshtastic.MeshInterface] = None
self._my_node_id: Optional[str] = None
self._message_callback: Optional[Callable[[MeshMessage], None]] = None
self._node_positions: dict[str, tuple[float, float]] = {}
self._node_names: dict[str, str] = {}
self._connected = False
self._loop: Optional[asyncio.AbstractEventLoop] = None
@property
def connected(self) -> bool:
"""Check if connected to node."""
return self._connected and self._interface is not None
@property
def my_node_id(self) -> Optional[str]:
"""Get our node's ID."""
return self._my_node_id
def connect(self) -> None:
"""Establish connection to Meshtastic node."""
logger.info(f"Connecting to Meshtastic node via {self.config.type}...")
try:
if self.config.type == "serial":
self._interface = meshtastic.serial_interface.SerialInterface(
devPath=self.config.serial_port
)
elif self.config.type == "tcp":
self._interface = meshtastic.tcp_interface.TCPInterface(
hostname=self.config.tcp_host, portNumber=self.config.tcp_port
)
else:
raise ValueError(f"Unknown connection type: {self.config.type}")
# Get our node info
my_info = self._interface.getMyNodeInfo()
self._my_node_id = f"!{my_info['num']:08x}"
logger.info(f"Connected as node {self._my_node_id}")
# Cache node info
self._cache_node_info()
# Subscribe to messages
pub.subscribe(self._on_receive, "meshtastic.receive.text")
pub.subscribe(self._on_node_update, "meshtastic.node.updated")
self._connected = True
except Exception as e:
logger.error(f"Failed to connect: {e}")
self._connected = False
raise
def disconnect(self) -> None:
"""Close connection to Meshtastic node."""
if self._interface:
try:
pub.unsubscribe(self._on_receive, "meshtastic.receive.text")
pub.unsubscribe(self._on_node_update, "meshtastic.node.updated")
except Exception:
pass
try:
self._interface.close()
except Exception as e:
logger.warning(f"Error closing interface: {e}")
self._interface = None
self._connected = False
logger.info("Disconnected from Meshtastic node")
def set_message_callback(
self, callback: Callable[[MeshMessage], None], loop: asyncio.AbstractEventLoop
) -> None:
"""Set callback for incoming messages.
Args:
callback: Async function to call with MeshMessage
loop: Event loop to schedule callback on
"""
self._message_callback = callback
self._loop = loop
def _cache_node_info(self) -> None:
"""Cache node names and positions from node database."""
if not self._interface:
return
for node_id, node in self._interface.nodes.items():
# Cache name
if user := node.get("user"):
name = user.get("shortName") or user.get("longName") or node_id
self._node_names[node_id] = name
# Cache position
if position := node.get("position"):
lat = position.get("latitude")
lon = position.get("longitude")
if lat is not None and lon is not None:
self._node_positions[node_id] = (lat, lon)
def _on_node_update(self, node, interface) -> None:
"""Handle node info updates."""
node_id = f"!{node['num']:08x}"
# Update name cache
if user := node.get("user"):
name = user.get("shortName") or user.get("longName") or node_id
self._node_names[node_id] = name
# Update position cache
if position := node.get("position"):
lat = position.get("latitude")
lon = position.get("longitude")
if lat is not None and lon is not None:
self._node_positions[node_id] = (lat, lon)
def _on_receive(self, packet, interface) -> None:
"""Handle incoming text message."""
if not self._message_callback or not self._loop:
return
try:
# Extract message details
sender_num = packet.get("fromId") or f"!{packet['from']:08x}"
to_num = packet.get("toId") or f"!{packet['to']:08x}"
decoded = packet.get("decoded", {})
text = decoded.get("text", "")
channel = packet.get("channel", 0)
if not text:
return
# Determine if DM (sent directly to us, not broadcast)
is_dm = to_num == self._my_node_id
# Get sender name
sender_name = self._node_names.get(sender_num, sender_num)
# Create message object
msg = MeshMessage(
sender_id=sender_num,
sender_name=sender_name,
text=text,
channel=channel,
is_dm=is_dm,
packet=packet,
)
# Attach position if available
if sender_num in self._node_positions:
msg._position = self._node_positions[sender_num]
# Schedule callback on event loop
self._loop.call_soon_threadsafe(
lambda m=msg: asyncio.create_task(self._message_callback(m))
)
except Exception as e:
logger.error(f"Error processing received message: {e}")
def send_message(
self,
text: str,
destination: Optional[str] = None,
channel: int = 0,
) -> bool:
"""Send a text message.
Args:
text: Message text to send
destination: Node ID for DM, or None for broadcast
channel: Channel index to send on
Returns:
True if send was initiated successfully
"""
if not self._interface:
logger.error("Cannot send: not connected")
return False
try:
if destination:
# DM to specific node
# Convert hex string to int if needed
if destination.startswith("!"):
dest_num = int(destination[1:], 16)
else:
dest_num = int(destination, 16)
self._interface.sendText(
text=text,
destinationId=dest_num,
channelIndex=channel,
)
else:
# Broadcast
self._interface.sendText(
text=text,
destinationId=BROADCAST_NUM,
channelIndex=channel,
)
logger.debug(f"Sent message to {destination or 'broadcast'}: {text[:50]}...")
return True
except Exception as e:
logger.error(f"Failed to send message: {e}")
return False
def get_node_position(self, node_id: str) -> Optional[tuple[float, float]]:
"""Get cached position for a node.
Args:
node_id: Node ID (hex string like "!abcd1234")
Returns:
Tuple of (latitude, longitude) or None if not available
"""
return self._node_positions.get(node_id)
def get_node_name(self, node_id: str) -> str:
"""Get cached name for a node.
Args:
node_id: Node ID (hex string like "!abcd1234")
Returns:
Node name or the node ID if name not available
"""
return self._node_names.get(node_id, node_id)

315
meshai/history.py Normal file
View file

@ -0,0 +1,315 @@
"""Conversation history management for MeshAI."""
import asyncio
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import aiosqlite
from .config import HistoryConfig
logger = logging.getLogger(__name__)
@dataclass
class ConversationMessage:
"""A single message in conversation history."""
role: str # "user" or "assistant"
content: str
timestamp: float
class ConversationHistory:
"""Manages per-user conversation history in SQLite."""
def __init__(self, config: HistoryConfig):
self.config = config
self._db_path = Path(config.database)
self._db: Optional[aiosqlite.Connection] = None
self._lock = asyncio.Lock()
async def initialize(self) -> None:
"""Initialize database and create tables."""
self._db = await aiosqlite.connect(self._db_path)
await self._db.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp REAL NOT NULL
)
""")
await self._db.execute("""
CREATE INDEX IF NOT EXISTS idx_user_timestamp
ON conversations (user_id, timestamp)
""")
# Summary table for rolling summary memory
await self._db.execute("""
CREATE TABLE IF NOT EXISTS conversation_summaries (
user_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
message_count INTEGER NOT NULL,
updated_at REAL NOT NULL
)
""")
await self._db.commit()
logger.info(f"Conversation history initialized at {self._db_path}")
async def close(self) -> None:
"""Close database connection."""
if self._db:
await self._db.close()
self._db = None
async def add_message(self, user_id: str, role: str, content: str) -> None:
"""Add a message to conversation history.
Args:
user_id: Node ID of the user
role: "user" or "assistant"
content: Message content
"""
if not self._db:
raise RuntimeError("Database not initialized")
async with self._lock:
await self._db.execute(
"""
INSERT INTO conversations (user_id, role, content, timestamp)
VALUES (?, ?, ?, ?)
""",
(user_id, role, content, time.time()),
)
await self._db.commit()
# Prune old messages for this user
await self._prune_history(user_id)
async def get_history(self, user_id: str) -> list[ConversationMessage]:
"""Get conversation history for a user.
Args:
user_id: Node ID of the user
Returns:
List of ConversationMessage objects, oldest first
"""
if not self._db:
raise RuntimeError("Database not initialized")
# Check for conversation timeout
cutoff_time = time.time() - self.config.conversation_timeout
async with self._lock:
cursor = await self._db.execute(
"""
SELECT role, content, timestamp
FROM conversations
WHERE user_id = ? AND timestamp > ?
ORDER BY timestamp ASC
LIMIT ?
""",
(user_id, cutoff_time, self.config.max_messages_per_user * 2),
)
rows = await cursor.fetchall()
return [
ConversationMessage(role=row[0], content=row[1], timestamp=row[2]) for row in rows
]
async def get_history_for_llm(self, user_id: str) -> list[dict]:
"""Get conversation history formatted for LLM API.
Args:
user_id: Node ID of the user
Returns:
List of dicts with 'role' and 'content' keys
"""
history = await self.get_history(user_id)
return [{"role": msg.role, "content": msg.content} for msg in history]
async def clear_history(self, user_id: str) -> int:
"""Clear conversation history for a user.
Args:
user_id: Node ID of the user
Returns:
Number of messages deleted
"""
if not self._db:
raise RuntimeError("Database not initialized")
async with self._lock:
cursor = await self._db.execute(
"DELETE FROM conversations WHERE user_id = ?",
(user_id,),
)
await self._db.commit()
return cursor.rowcount
async def _prune_history(self, user_id: str) -> None:
"""Remove old messages beyond the limit for a user."""
# Get count of messages for user
cursor = await self._db.execute(
"SELECT COUNT(*) FROM conversations WHERE user_id = ?",
(user_id,),
)
count = (await cursor.fetchone())[0]
# Remove oldest if over limit (keep pairs, so multiply by 2)
max_messages = self.config.max_messages_per_user * 2
if count > max_messages:
excess = count - max_messages
await self._db.execute(
"""
DELETE FROM conversations
WHERE id IN (
SELECT id FROM conversations
WHERE user_id = ?
ORDER BY timestamp ASC
LIMIT ?
)
""",
(user_id, excess),
)
await self._db.commit()
async def get_stats(self) -> dict:
"""Get statistics about conversation history.
Returns:
Dict with 'total_messages', 'unique_users', 'oldest_message'
"""
if not self._db:
raise RuntimeError("Database not initialized")
async with self._lock:
# Total messages
cursor = await self._db.execute("SELECT COUNT(*) FROM conversations")
total = (await cursor.fetchone())[0]
# Unique users
cursor = await self._db.execute("SELECT COUNT(DISTINCT user_id) FROM conversations")
users = (await cursor.fetchone())[0]
# Oldest message
cursor = await self._db.execute("SELECT MIN(timestamp) FROM conversations")
oldest = (await cursor.fetchone())[0]
return {
"total_messages": total,
"unique_users": users,
"oldest_message": oldest,
}
async def cleanup_expired(self) -> int:
"""Remove all expired conversations.
Returns:
Number of messages deleted
"""
if not self._db:
raise RuntimeError("Database not initialized")
cutoff_time = time.time() - self.config.conversation_timeout
async with self._lock:
cursor = await self._db.execute(
"DELETE FROM conversations WHERE timestamp < ?",
(cutoff_time,),
)
await self._db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} expired conversation messages")
return deleted
# -------------------------------------------------------------------------
# Summary Storage Methods (for Rolling Summary Memory)
# -------------------------------------------------------------------------
async def store_summary(
self, user_id: str, summary: str, message_count: int
) -> None:
"""Store conversation summary.
Args:
user_id: Node ID of user
summary: Summary text
message_count: Number of messages summarized
"""
if not self._db:
raise RuntimeError("Database not initialized")
async with self._lock:
await self._db.execute(
"""
INSERT OR REPLACE INTO conversation_summaries
(user_id, summary, message_count, updated_at)
VALUES (?, ?, ?, ?)
""",
(user_id, summary, message_count, time.time()),
)
await self._db.commit()
async def get_summary(self, user_id: str) -> Optional[dict]:
"""Get conversation summary for user.
Args:
user_id: Node ID of user
Returns:
Dict with 'summary', 'message_count', 'updated_at' or None
"""
if not self._db:
raise RuntimeError("Database not initialized")
async with self._lock:
cursor = await self._db.execute(
"""
SELECT summary, message_count, updated_at
FROM conversation_summaries
WHERE user_id = ?
""",
(user_id,),
)
row = await cursor.fetchone()
if not row:
return None
return {
"summary": row[0],
"message_count": row[1],
"updated_at": row[2],
}
async def clear_summary(self, user_id: str) -> None:
"""Clear summary for user (e.g., on history reset).
Args:
user_id: Node ID of user
"""
if not self._db:
raise RuntimeError("Database not initialized")
async with self._lock:
await self._db.execute(
"DELETE FROM conversation_summaries WHERE user_id = ?",
(user_id,),
)
await self._db.commit()

282
meshai/main.py Normal file
View file

@ -0,0 +1,282 @@
"""Main entry point for MeshAI."""
import argparse
import asyncio
import logging
import signal
import sys
import time
from pathlib import Path
from typing import Optional
from . import __version__
from .backends import AnthropicBackend, GoogleBackend, LLMBackend, OpenAIBackend
from .cli import run_configurator
from .commands import CommandDispatcher
from .commands.dispatcher import create_dispatcher
from .commands.status import set_start_time
from .config import Config, load_config
from .connector import MeshConnector, MeshMessage
from .history import ConversationHistory
from .responder import Responder
from .router import MessageRouter, RouteType
logger = logging.getLogger(__name__)
class MeshAI:
"""Main application class."""
def __init__(self, config: Config):
self.config = config
self.connector: Optional[MeshConnector] = None
self.history: Optional[ConversationHistory] = None
self.dispatcher: Optional[CommandDispatcher] = None
self.llm: Optional[LLMBackend] = None
self.router: Optional[MessageRouter] = None
self.responder: Optional[Responder] = None
self._running = False
self._loop: Optional[asyncio.AbstractEventLoop] = None
async def start(self) -> None:
"""Start the bot."""
logger.info(f"Starting MeshAI v{__version__}")
set_start_time(time.time())
# Initialize components
await self._init_components()
# Connect to Meshtastic
self.connector.connect()
self.connector.set_message_callback(self._on_message, asyncio.get_event_loop())
self._running = True
self._loop = asyncio.get_event_loop()
# Write PID file
self._write_pid()
logger.info("MeshAI started successfully")
# Keep running
while self._running:
await asyncio.sleep(1)
# Periodic cleanup
if int(time.time()) % 3600 == 0: # Every hour
await self.history.cleanup_expired()
async def stop(self) -> None:
"""Stop the bot."""
logger.info("Stopping MeshAI...")
self._running = False
if self.connector:
self.connector.disconnect()
if self.history:
await self.history.close()
if self.llm:
await self.llm.close()
self._remove_pid()
logger.info("MeshAI stopped")
async def _init_components(self) -> None:
"""Initialize all components."""
# Conversation history
self.history = ConversationHistory(self.config.history)
await self.history.initialize()
# Command dispatcher
self.dispatcher = create_dispatcher()
# LLM backend
api_key = self.config.resolve_api_key()
if not api_key:
logger.warning("No API key configured - LLM responses will fail")
# Memory config
mem_cfg = self.config.memory
window_size = mem_cfg.window_size if mem_cfg.enabled else 0
summarize_threshold = mem_cfg.summarize_threshold
backend = self.config.llm.backend.lower()
if backend == "openai":
self.llm = OpenAIBackend(
self.config.llm, api_key, window_size, summarize_threshold
)
elif backend == "anthropic":
self.llm = AnthropicBackend(
self.config.llm, api_key, window_size, summarize_threshold
)
elif backend == "google":
self.llm = GoogleBackend(
self.config.llm, api_key, window_size, summarize_threshold
)
else:
logger.warning(f"Unknown backend '{backend}', defaulting to OpenAI")
self.llm = OpenAIBackend(
self.config.llm, api_key, window_size, summarize_threshold
)
# Meshtastic connector
self.connector = MeshConnector(self.config.connection)
# Message router
self.router = MessageRouter(
self.config, self.connector, self.history, self.dispatcher, self.llm
)
# Responder
self.responder = Responder(self.config.response, self.connector)
async def _on_message(self, message: MeshMessage) -> None:
"""Handle incoming message."""
try:
# Check if we should respond
if not self.router.should_respond(message):
return
logger.info(
f"Processing message from {message.sender_name} ({message.sender_id}): "
f"{message.text[:50]}..."
)
# Route the message
result = await self.router.route(message)
if result.route_type == RouteType.IGNORE:
return
# Determine response
if result.route_type == RouteType.COMMAND:
response = result.response
elif result.route_type == RouteType.LLM:
response = await self.router.generate_llm_response(message, result.query)
else:
return
if not response:
return
# Send response
if message.is_dm:
# Reply as DM
await self.responder.send_response(
text=response,
destination=message.sender_id,
channel=message.channel,
)
else:
# Reply on channel
formatted = self.responder.format_channel_response(
response, message.sender_name, mention_sender=True
)
await self.responder.send_response(
text=formatted,
destination=None,
channel=message.channel,
)
except Exception as e:
logger.error(f"Error handling message: {e}", exc_info=True)
def _write_pid(self) -> None:
"""Write PID file."""
pid_file = Path("/tmp/meshai.pid")
pid_file.write_text(str(os.getpid()))
def _remove_pid(self) -> None:
"""Remove PID file."""
pid_file = Path("/tmp/meshai.pid")
if pid_file.exists():
pid_file.unlink()
import os
def setup_logging(verbose: bool = False) -> None:
"""Configure logging."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="MeshAI - LLM-powered Meshtastic assistant",
prog="meshai",
)
parser.add_argument(
"--version", "-V", action="version", version=f"%(prog)s {__version__}"
)
parser.add_argument(
"--config", "-c", action="store_true", help="Launch configuration tool"
)
parser.add_argument(
"--config-file",
"-f",
type=Path,
default=Path("config.yaml"),
help="Path to config file (default: config.yaml)",
)
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
args = parser.parse_args()
setup_logging(args.verbose)
# Launch configurator if requested
if args.config:
run_configurator(args.config_file)
return
# Load config
config = load_config(args.config_file)
# Check if config exists
if not args.config_file.exists():
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)
# Handle SIGHUP for config reload
def reload_handler(sig, frame):
logger.info("Received SIGHUP - reloading config")
# For now, just log - full reload would require more work
# Could reload config and reinitialize components
signal.signal(signal.SIGHUP, reload_handler)
try:
loop.run_until_complete(bot.start())
except KeyboardInterrupt:
pass
finally:
loop.run_until_complete(bot.stop())
loop.close()
if __name__ == "__main__":
main()

165
meshai/memory.py Normal file
View file

@ -0,0 +1,165 @@
"""Lightweight rolling summary memory manager for conversation context optimization."""
import logging
import time
from dataclasses import dataclass
from typing import Optional
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
@dataclass
class ConversationSummary:
"""Summary of conversation history."""
summary: str
last_updated: float
message_count: int
class RollingSummaryMemory:
"""Manages conversation summaries with recent message window.
Strategy:
- Keep last N message pairs (window_size) in full
- Summarize everything before the window
- Update summary when old messages accumulate
Example (window_size=4):
Messages 1-10: Summarized to "User discussed weather and plans"
Messages 11-18: Kept in full (last 4 pairs)
Context sent: [Summary] + [Messages 11-18]
This achieves ~70-80% token reduction for long conversations
while preserving both long-term context (via summary) and
recent context (via raw messages).
"""
def __init__(
self,
client: AsyncOpenAI,
model: str,
window_size: int = 4,
summarize_threshold: int = 8,
):
"""Initialize rolling summary memory.
Args:
client: AsyncOpenAI client for generating summaries
model: Model name to use for summarization
window_size: Number of recent message pairs to keep in full
summarize_threshold: Messages to accumulate before re-summarizing
"""
self._client = client
self._model = model
self._window_size = window_size
self._summarize_threshold = summarize_threshold
# In-memory cache of summaries (loaded from DB on startup)
self._summaries: dict[str, ConversationSummary] = {}
async def get_context_messages(
self,
user_id: str,
full_history: list[dict],
) -> tuple[Optional[str], list[dict]]:
"""Get optimized context: summary + recent messages.
Args:
user_id: User identifier
full_history: Full message history from database
Returns:
Tuple of (summary_text, recent_messages)
summary_text is None if conversation is short
"""
# Short conversation - no summary needed
if len(full_history) <= self._window_size * 2:
return None, full_history
# Split into old (to summarize) and recent (keep raw)
split_point = -(self._window_size * 2)
old_messages = full_history[:split_point]
recent_messages = full_history[split_point:]
# Get or create summary
summary = await self._get_or_create_summary(user_id, old_messages)
return summary.summary, recent_messages
async def _get_or_create_summary(
self,
user_id: str,
messages: list[dict],
) -> ConversationSummary:
"""Get cached summary or create new one."""
# Check cache
if user_id in self._summaries:
cached = self._summaries[user_id]
# Reuse if message count is close (within threshold)
if abs(cached.message_count - len(messages)) < self._summarize_threshold:
return cached
# Generate new summary
logger.debug(f"Generating summary for {user_id} ({len(messages)} messages)")
summary_text = await self._summarize(messages)
summary = ConversationSummary(
summary=summary_text,
last_updated=time.time(),
message_count=len(messages),
)
self._summaries[user_id] = summary
return summary
async def _summarize(self, messages: list[dict]) -> str:
"""Generate summary using LLM."""
if not messages:
return "No previous conversation."
# Format conversation
conversation = "\n".join(
[f"{msg['role'].upper()}: {msg['content']}" for msg in messages]
)
prompt = f"""Summarize this conversation in 2-3 concise sentences. Focus on:
- Main topics discussed
- Important context or user preferences
- Key information to remember
Conversation:
{conversation}
Summary (2-3 sentences):"""
try:
response = await self._client.chat.completions.create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=150,
temperature=0.3,
)
content = response.choices[0].message.content
return content.strip() if content else f"Previous conversation: {len(messages)} messages."
except Exception as e:
logger.warning(f"Failed to generate summary: {e}")
# Fallback - provide basic context
return f"Previous conversation: {len(messages)} messages about various topics."
def load_summary(self, user_id: str, summary: ConversationSummary) -> None:
"""Load summary from database into cache."""
self._summaries[user_id] = summary
def clear_summary(self, user_id: str) -> None:
"""Clear cached summary for user."""
self._summaries.pop(user_id, None)
def get_cached_summary(self, user_id: str) -> Optional[ConversationSummary]:
"""Get cached summary for user (for persistence)."""
return self._summaries.get(user_id)

173
meshai/responder.py Normal file
View file

@ -0,0 +1,173 @@
"""Response handling - delays and message chunking."""
import asyncio
import logging
import random
from typing import Optional
from .config import ResponseConfig
from .connector import MeshConnector
logger = logging.getLogger(__name__)
class Responder:
"""Handles response formatting, chunking, and delivery."""
def __init__(self, config: ResponseConfig, connector: MeshConnector):
self.config = config
self.connector = connector
async def send_response(
self,
text: str,
destination: Optional[str] = None,
channel: int = 0,
) -> bool:
"""Send a response with delay and chunking.
Args:
text: Response text (will be chunked if too long)
destination: Node ID for DM, or None for channel broadcast
channel: Channel to send on
Returns:
True if all chunks sent successfully
"""
# Chunk the message
chunks = self._chunk_message(text)
# Limit to max messages
if len(chunks) > self.config.max_messages:
chunks = chunks[: self.config.max_messages]
# Truncate last chunk to indicate more was cut
if chunks:
last = chunks[-1]
if len(last) > self.config.max_length - 3:
chunks[-1] = last[: self.config.max_length - 3] + "..."
success = True
for i, chunk in enumerate(chunks):
# Apply delay before sending
delay = random.uniform(self.config.delay_min, self.config.delay_max)
await asyncio.sleep(delay)
# Send chunk
sent = self.connector.send_message(
text=chunk,
destination=destination,
channel=channel,
)
if not sent:
logger.error(f"Failed to send chunk {i + 1}/{len(chunks)}")
success = False
break
logger.debug(f"Sent chunk {i + 1}/{len(chunks)}: {chunk[:50]}...")
return success
def _chunk_message(self, text: str) -> list[str]:
"""Split message into chunks respecting max_length.
Tries to break at word boundaries when possible.
Args:
text: Text to chunk
Returns:
List of chunks
"""
max_len = self.config.max_length
if len(text) <= max_len:
return [text]
chunks = []
remaining = text
while remaining:
if len(remaining) <= max_len:
chunks.append(remaining)
break
# Find a good break point
chunk = remaining[:max_len]
# Try to break at word boundary
break_point = self._find_break_point(chunk)
if break_point > 0:
chunks.append(remaining[:break_point].rstrip())
remaining = remaining[break_point:].lstrip()
else:
# No good break point, hard cut
chunks.append(chunk)
remaining = remaining[max_len:]
return chunks
def _find_break_point(self, text: str) -> int:
"""Find best break point in text.
Prefers: sentence end > comma/semicolon > space
Args:
text: Text to find break in
Returns:
Index to break at, or 0 if no good break found
"""
# Look for sentence endings
for char in ".!?":
pos = text.rfind(char)
if pos > len(text) // 2: # Only if in second half
return pos + 1
# Look for clause breaks
for char in ",;:":
pos = text.rfind(char)
if pos > len(text) // 2:
return pos + 1
# Look for word boundary
pos = text.rfind(" ")
if pos > len(text) // 3: # Only if past first third
return pos
return 0
def format_dm_response(self, text: str, sender_name: str) -> str:
"""Format response for DM context.
Args:
text: Response text
sender_name: Name of recipient
Returns:
Formatted response (currently unchanged)
"""
# Could prefix with name or add other formatting
return text
def format_channel_response(
self, text: str, sender_name: str, mention_sender: bool = False
) -> str:
"""Format response for channel context.
Args:
text: Response text
sender_name: Name of sender being replied to
mention_sender: Whether to prefix with sender's name
Returns:
Formatted response
"""
if mention_sender:
# Check if adding prefix would exceed max length
prefix = f"@{sender_name}: "
if len(prefix) + len(text) <= self.config.max_length * self.config.max_messages:
return prefix + text
return text

190
meshai/router.py Normal file
View file

@ -0,0 +1,190 @@
"""Message routing logic for MeshAI."""
import logging
import re
from dataclasses import dataclass
from enum import Enum, auto
from typing import Optional
from .backends.base import LLMBackend
from .commands import CommandContext, CommandDispatcher
from .config import Config
from .connector import MeshConnector, MeshMessage
from .history import ConversationHistory
logger = logging.getLogger(__name__)
class RouteType(Enum):
"""Type of message routing."""
IGNORE = auto() # Don't respond
COMMAND = auto() # Bang command
LLM = auto() # Route to LLM
@dataclass
class RouteResult:
"""Result of routing decision."""
route_type: RouteType
response: Optional[str] = None # For commands, the response
query: Optional[str] = None # For LLM, the cleaned query
class MessageRouter:
"""Routes incoming messages to appropriate handlers."""
def __init__(
self,
config: Config,
connector: MeshConnector,
history: ConversationHistory,
dispatcher: CommandDispatcher,
llm_backend: LLMBackend,
):
self.config = config
self.connector = connector
self.history = history
self.dispatcher = dispatcher
self.llm = llm_backend
# Compile mention pattern
bot_name = re.escape(config.bot.name)
self._mention_pattern = re.compile(rf"@{bot_name}\b", re.IGNORECASE)
def should_respond(self, message: MeshMessage) -> bool:
"""Determine if we should respond to this message.
Args:
message: Incoming message
Returns:
True if we should process this message
"""
# Always ignore our own messages
if message.sender_id == self.connector.my_node_id:
return False
# Check if DM
if message.is_dm:
return self.config.bot.respond_to_dms
# Check channel filtering
if self.config.channels.mode == "whitelist":
if message.channel not in self.config.channels.whitelist:
return False
# Check for @mention
if self.config.bot.respond_to_mentions:
if self._mention_pattern.search(message.text):
return True
# Check for bang command (always respond to commands)
if self.dispatcher.is_command(message.text):
return True
# Not a DM, no mention, no command - ignore
return False
async def route(self, message: MeshMessage) -> RouteResult:
"""Route a message and generate response.
Args:
message: Incoming message to route
Returns:
RouteResult with routing decision and any response
"""
text = message.text.strip()
# Check for bang command first
if self.dispatcher.is_command(text):
context = self._make_command_context(message)
response = await self.dispatcher.dispatch(text, context)
return RouteResult(RouteType.COMMAND, response=response)
# Clean up the message (remove @mention)
query = self._clean_query(text)
if not query:
return RouteResult(RouteType.IGNORE)
# Route to LLM
return RouteResult(RouteType.LLM, query=query)
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
"""Generate LLM response for a message.
Args:
message: Original message
query: Cleaned query text
Returns:
Generated response
"""
# Add user message to history
await self.history.add_message(message.sender_id, "user", query)
# Get conversation history
history = await self.history.get_history_for_llm(message.sender_id)
# Generate response with user_id for memory optimization
try:
response = await self.llm.generate(
messages=history,
system_prompt=self.config.llm.system_prompt,
max_tokens=300,
user_id=message.sender_id, # Enable memory optimization
)
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)
return response
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:
"""Remove @mention from query text."""
# Remove @botname mention
cleaned = self._mention_pattern.sub("", text)
# Clean up extra whitespace
cleaned = " ".join(cleaned.split())
return cleaned.strip()
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,
)