Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
AI_MODEL=openai:gpt-4o-mini
# OPENAI_API_KEY=your_openai_api_key_here

# Bot Behavior Configuration
# Activation phrase for channel messages (not required for DMs)
ACTIVATION_PHRASE=@bot
# Channel to listen to (0 for General, or specific channel name/number)
LISTEN_CHANNEL=0
# Optional custom prompt file for domain-specific knowledge
# CUSTOM_PROMPT_FILE=prompts/custom.txt

# MeshCore Configuration
MESHCORE_CONNECTION_TYPE=mock
# MESHCORE_PORT=/dev/ttyUSB0
Expand All @@ -13,12 +21,11 @@ MESHCORE_CONNECTION_TYPE=mock
# MESHCORE_AUTO_RECONNECT=true

# Storage Configuration
MEMORY_PATH=memory.json
KNOWLEDGE_DIR=knowledge
MEMORY_PATH=memory_metadata.json

# Knowledge Base Configuration
KNOWLEDGE_USE_VECTORS=false
KNOWLEDGE_MAX_RESULTS=5
# Memori Configuration (for conversation memory)
# MEMORI_DATABASE_URL=sqlite:///memori_conversations.db
# MEMORI_DATABASE_URL=postgresql://user:pass@localhost/memori
Comment on lines +27 to +28
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation: The .env.example file documents MEMORI_DATABASE_URL environment variable on lines 27-28, but this variable is not used anywhere in the code. The MemoryManager.__init__ accepts a database_url parameter but doesn't read it from environment variables. Either remove these lines from .env.example or add support for reading this environment variable in memory.py.

Suggested change
# MEMORI_DATABASE_URL=sqlite:///memori_conversations.db
# MEMORI_DATABASE_URL=postgresql://user:pass@localhost/memori

Copilot uses AI. Check for mistakes.

# Logging Configuration
LOG_LEVEL=INFO
Expand Down
18 changes: 18 additions & 0 deletions prompts/example.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
You are a helpful assistant for a community mesh network.

Domain Knowledge:
- Our mesh network uses LoRa technology for long-range, low-power communication
- We operate on the 915 MHz ISM band (North America)
- Typical range is 2-10km depending on terrain and antenna setup
- The network is decentralized with no single point of failure
- All messages are encrypted end-to-end

Common Questions:
- Network coverage: We have nodes in downtown, the university campus, and the industrial park
- How to join: Users need a compatible LoRa device (T-Beam, Heltec, etc.) running MeshCore firmware
- Troubleshooting: Check antenna connection, verify frequency settings, ensure latest firmware

Community Guidelines:
- Be respectful and helpful to all users
- Keep messages concise due to bandwidth limitations
- Emergency messages take priority on the network
6 changes: 1 addition & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies = [
"aiofiles>=24.0.0",
"python-dotenv>=1.0.0",
"rich>=13.0.0",
"memorisdk>=2.3.0",
]

[project.optional-dependencies]
Expand All @@ -37,11 +38,6 @@ dev = [
"isort>=5.0.0",
"pre-commit>=3.0.0",
]
knowledge = [
"sentence-transformers>=3.0.0",
"faiss-cpu>=1.8.0",
"numpy>=1.24.0",
]

[project.scripts]
meshbot = "meshbot.main:main"
Expand Down
138 changes: 77 additions & 61 deletions src/meshbot/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,17 @@
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, List, Any, Dict
from typing import Any, Dict, List, Optional
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'List' is not used.

Suggested change
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional

Copilot uses AI. Check for mistakes.

from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext

from .memory import MemoryManager
from .meshcore_interface import (
MeshCoreInterface,
MeshCoreMessage,
create_meshcore_interface,
)
from .memory import MemoryManager
from .knowledge import create_knowledge_base, SimpleKnowledgeBase

logger = logging.getLogger(__name__)

Expand All @@ -26,7 +25,6 @@ class MeshBotDependencies:

meshcore: MeshCoreInterface
memory: MemoryManager
knowledge: SimpleKnowledgeBase


class AgentResponse(BaseModel):
Expand All @@ -50,21 +48,24 @@ class MeshBotAgent:
def __init__(
self,
model: str = "openai:gpt-4o-mini",
knowledge_dir: Path = Path("knowledge"),
memory_path: Optional[Path] = None,
meshcore_connection_type: str = "mock",
activation_phrase: str = "@bot",
listen_channel: str = "0",
custom_prompt: Optional[str] = None,
**meshcore_kwargs,
):
self.model = model
self.knowledge_dir = knowledge_dir
self.memory_path = memory_path
self.meshcore_connection_type = meshcore_connection_type
self.activation_phrase = activation_phrase.lower()
self.listen_channel = listen_channel
self.custom_prompt = custom_prompt
self.meshcore_kwargs = meshcore_kwargs

# Initialize components
self.meshcore: Optional[MeshCoreInterface] = None
self.memory: Optional[MemoryManager] = None
self.knowledge: Optional[SimpleKnowledgeBase] = None
self.agent: Optional[Agent[MeshBotDependencies, AgentResponse]] = None

self._running = False
Expand All @@ -82,26 +83,35 @@ async def initialize(self) -> None:
)

# Initialize memory manager
self.memory = MemoryManager(self.memory_path or Path("memory.json"))
self.memory = MemoryManager(self.memory_path or Path("memory_metadata.json"))
await self.memory.load()

# Initialize knowledge base
self.knowledge = create_knowledge_base(self.knowledge_dir)
await self.knowledge.load()
# Enable Memori for automatic conversation memory
self.memory.enable_memori()

# Build agent instructions
base_instructions = (
"You are MeshBot, an AI assistant that communicates through the MeshCore network. "
"You are helpful, concise, and knowledgeable. "
"Always be friendly and professional in your responses. "
"When users send 'ping', respond with 'pong'. "
"Keep responses relatively short and clear for network communication."
)

# Add custom prompt if provided
if self.custom_prompt:
instructions = (
f"{base_instructions}\n\nAdditional Context:\n{self.custom_prompt}"
)
else:
instructions = base_instructions

# Create Pydantic AI agent
self.agent = Agent(
self.model,
deps_type=MeshBotDependencies,
output_type=AgentResponse,
instructions=(
"You are MeshBot, an AI assistant that communicates through the MeshCore network. "
"You are helpful, concise, and knowledgeable. "
"You can answer questions, help with tasks, and provide information from your knowledge base. "
"Always be friendly and professional in your responses. "
"When users send 'ping', respond with 'pong'. "
"Keep responses relatively short and clear for network communication."
),
instructions=instructions,
)

# Register tools
Expand All @@ -115,26 +125,6 @@ async def initialize(self) -> None:
def _register_tools(self) -> None:
"""Register tools for the agent."""

@self.agent.tool
async def search_knowledge(
ctx: RunContext[MeshBotDependencies], query: str
) -> str:
"""Search the knowledge base for information."""
try:
results = await ctx.deps.knowledge.search(query, max_results=3)
if not results:
return "No relevant information found in the knowledge base."

response = "Found the following information:\n\n"
for i, result in enumerate(results, 1):
response += f"{i}. {result.excerpt}\n"
response += f" Source: {result.chunk.source_file}\n\n"

return response.strip()
except Exception as e:
logger.error(f"Error searching knowledge base: {e}")
return "Error searching knowledge base."

@self.agent.tool
async def get_user_info(
ctx: RunContext[MeshBotDependencies], user_id: str
Expand Down Expand Up @@ -241,31 +231,61 @@ async def stop(self) -> None:

logger.info("MeshBot agent stopped")

def _should_respond_to_message(self, message: MeshCoreMessage) -> bool:
"""
Determine if the bot should respond to this message.

Rules:
- Always respond to DMs (direct messages)
- For channel messages, only respond if:
1. Message is on the configured listen_channel
2. Message contains the activation_phrase
"""
# Always respond to DMs
if message.message_type == "direct":
return True

# For channel messages, check channel and activation phrase
if message.message_type == "channel":
# Check if it's the channel we're listening to
# Handle both string channel names and numeric IDs
message_channel = str(getattr(message, "channel", "0"))
if message_channel != self.listen_channel:
logger.debug(
f"Ignoring message from channel {message_channel}, "
f"listening to {self.listen_channel}"
)
return False

# Check for activation phrase (case-insensitive)
if self.activation_phrase.lower() in message.content.lower():
return True
else:
logger.debug(
f"Ignoring channel message without activation phrase: {message.content}"
)
return False

# Default: don't respond to broadcast messages or unknown types
return False

async def _handle_message(self, message: MeshCoreMessage) -> None:
"""Handle incoming message."""
try:
logger.info(f"Received message from {message.sender}: {message.content}")

# Check if we should respond to this message
if not self._should_respond_to_message(message):
return

# Store message in memory
await self.memory.add_message(message, is_from_user=True)

# Get conversation context
context = await self.memory.get_recent_context(
message.sender, max_messages=5
)

# Create dependencies for this interaction
deps = MeshBotDependencies(
meshcore=self.meshcore, memory=self.memory, knowledge=self.knowledge
)
deps = MeshBotDependencies(meshcore=self.meshcore, memory=self.memory)

# Prepare prompt with context
prompt = message.content
if context:
prompt = f"Recent conversation:\n{context}\n\nCurrent message: {message.content}"

# Run agent
result = await self.agent.run(prompt, deps=deps)
# Run agent (Memori will automatically inject conversation context)
result = await self.agent.run(message.content, deps=deps)

# Send response
response = result.output.response
Expand Down Expand Up @@ -338,18 +358,14 @@ async def get_status(self) -> Dict[str, Any]:
status = {
"running": self._running,
"model": self.model,
"meshcore_connected": self.meshcore.is_connected()
if self.meshcore
else False,
"meshcore_connected": (
self.meshcore.is_connected() if self.meshcore else False
),
"meshcore_type": self.meshcore_connection_type,
}

if self.memory:
memory_stats = await self.memory.get_statistics()
status["memory"] = memory_stats

if self.knowledge:
knowledge_stats = await self.knowledge.get_statistics()
status["knowledge"] = knowledge_stats

return status
35 changes: 14 additions & 21 deletions src/meshbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Dict, Any
from typing import Any, Dict, Optional
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'Any' is not used.
Import of 'Dict' is not used.

Suggested change
from typing import Any, Dict, Optional
from typing import Optional

Copilot uses AI. Check for mistakes.

from dotenv import load_dotenv

Expand Down Expand Up @@ -52,6 +52,19 @@ class AIConfig:
temperature: float = field(
default_factory=lambda: float(os.getenv("AI_TEMPERATURE", "0.7"))
)
activation_phrase: str = field(
default_factory=lambda: os.getenv("ACTIVATION_PHRASE", "@bot")
)
listen_channel: str = field(
default_factory=lambda: os.getenv("LISTEN_CHANNEL", "0")
)
custom_prompt_file: Optional[Path] = field(default=None)

def __post_init__(self) -> None:
"""Post-initialization to handle custom_prompt_file."""
prompt_file_env = os.getenv("CUSTOM_PROMPT_FILE")
if prompt_file_env and not self.custom_prompt_file:
self.custom_prompt_file = Path(prompt_file_env)


@dataclass
Expand All @@ -69,22 +82,6 @@ class MemoryConfig:
)


@dataclass
class KnowledgeConfig:
"""Configuration for knowledge base."""

knowledge_dir: Path = field(
default_factory=lambda: Path(os.getenv("KNOWLEDGE_DIR", "knowledge"))
)
use_vectors: bool = field(
default_factory=lambda: os.getenv("KNOWLEDGE_USE_VECTORS", "false").lower()
== "true"
)
max_search_results: int = field(
default_factory=lambda: int(os.getenv("KNOWLEDGE_MAX_RESULTS", "5"))
)


@dataclass
class LoggingConfig:
"""Configuration for logging."""
Expand All @@ -105,7 +102,6 @@ class MeshBotConfig:
meshcore: MeshCoreConfig = field(default_factory=MeshCoreConfig)
ai: AIConfig = field(default_factory=AIConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig)
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
logging: LoggingConfig = field(default_factory=LoggingConfig)

@classmethod
Expand All @@ -123,7 +119,6 @@ def from_file(cls, config_path: Optional[Path]) -> "MeshBotConfig":
meshcore=MeshCoreConfig(**data.get("meshcore", {})),
ai=AIConfig(**data.get("ai", {})),
memory=MemoryConfig(**data.get("memory", {})),
knowledge=KnowledgeConfig(**data.get("knowledge", {})),
logging=LoggingConfig(**data.get("logging", {})),
)

Expand All @@ -135,7 +130,6 @@ def to_file(self, config_path: Path) -> None:
"meshcore": self.meshcore.__dict__,
"ai": self.ai.__dict__,
"memory": self.memory.__dict__,
"knowledge": self.knowledge.__dict__,
"logging": self.logging.__dict__,
}

Expand All @@ -159,7 +153,6 @@ def validate(self) -> None:

# Validate paths
self.memory.storage_path.parent.mkdir(parents=True, exist_ok=True)
self.knowledge.knowledge_dir.mkdir(parents=True, exist_ok=True)

if self.logging.file_path:
self.logging.file_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down
Loading
Loading