mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 15:44:39 +02:00
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:
commit
fd3f995ebb
43 changed files with 7947 additions and 0 deletions
4
meshai/__init__.py
Normal file
4
meshai/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""MeshAI - LLM-powered Meshtastic mesh network assistant."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "K7ZVX"
|
||||
6
meshai/__main__.py
Normal file
6
meshai/__main__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Allow running as python -m meshai."""
|
||||
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
meshai/backends/__init__.py
Normal file
8
meshai/backends/__init__.py
Normal 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"]
|
||||
205
meshai/backends/anthropic_backend.py
Normal file
205
meshai/backends/anthropic_backend.py
Normal 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
57
meshai/backends/base.py
Normal 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
|
||||
215
meshai/backends/google_backend.py
Normal file
215
meshai/backends/google_backend.py
Normal 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
|
||||
132
meshai/backends/openai_backend.py
Normal file
132
meshai/backends/openai_backend.py
Normal 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
5
meshai/cli/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""CLI tools for MeshAI."""
|
||||
|
||||
from .configurator import run_configurator
|
||||
|
||||
__all__ = ["run_configurator"]
|
||||
612
meshai/cli/configurator.py
Normal file
612
meshai/cli/configurator.py
Normal 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()
|
||||
6
meshai/commands/__init__.py
Normal file
6
meshai/commands/__init__.py
Normal 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
72
meshai/commands/base.py
Normal 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
|
||||
116
meshai/commands/dispatcher.py
Normal file
116
meshai/commands/dispatcher.py
Normal 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
25
meshai/commands/help.py
Normal 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
15
meshai/commands/ping.py
Normal 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
23
meshai/commands/reset.py
Normal 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
43
meshai/commands/status.py
Normal 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
220
meshai/commands/weather.py
Normal 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
233
meshai/config.py
Normal 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
273
meshai/connector.py
Normal 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
315
meshai/history.py
Normal 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
282
meshai/main.py
Normal 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
165
meshai/memory.py
Normal 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
173
meshai/responder.py
Normal 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
190
meshai/router.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue