mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +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
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue