feat: Dynamic identity system prompt from bot config

- Build system prompt dynamically using bot.name and bot.owner from config
- Reorder prompt: identity -> static prompt -> MeshMonitor (conditional) -> mesh context
- MeshMonitor description only injected when meshmonitor.enabled is true
- Update default system_prompt to static parts only (commands, architecture, rules)
- Fix meshmonitor.py to handle trigger arrays (not just strings)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root 2026-05-03 05:45:58 +00:00
commit 5f66b69c9c
5 changed files with 296 additions and 298 deletions

View file

@ -79,7 +79,8 @@ class Configurator:
table.add_row("7", "Context", f"{ctx_status} {self.config.context.max_context_items} items")
table.add_row("8", "Weather", f"{self.config.weather.primary}")
mm_status = self._status_icon(self.config.meshmonitor.enabled)
table.add_row("9", "MeshMonitor Sync", f"{mm_status}")
mm_url = self.config.meshmonitor.url or "[dim]not set[/dim]"
table.add_row("9", "MeshMonitor Sync", f"{mm_status} {mm_url}")
table.add_row("10", "Setup Wizard", "[dim]First-time setup[/dim]")
console.print(table)
@ -89,13 +90,13 @@ class Configurator:
if self.modified:
console.print("[yellow]* Unsaved changes[/yellow]")
console.print()
console.print("[white]10. Save[/white] [dim]Save config, stay in menu[/dim]")
console.print("[green]11. Save & Restart Bot[/green] [dim]Apply changes now[/dim]")
console.print("[white]12. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]")
console.print("[white]13. Exit without Saving[/white]")
console.print("[white]11. Save[/white] [dim]Save config, stay in menu[/dim]")
console.print("[green]12. Save & Restart Bot[/green] [dim]Apply changes now[/dim]")
console.print("[white]13. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]")
console.print("[white]14. Exit without Saving[/white]")
console.print()
choice = IntPrompt.ask("Select option", default=11)
choice = IntPrompt.ask("Select option", default=12)
if choice == 1:
self._bot_settings()
@ -606,26 +607,23 @@ class Configurator:
self.config.memory.summarize_threshold = value
self.modified = True
def _meshmonitor_settings(self) -> None:
"""MeshMonitor sync settings submenu."""
while True:
self._clear()
console.print("[bold]MeshMonitor Sync Settings[/bold]
")
console.print("[dim]Auto-ignore messages that match MeshMonitor trigger patterns.[/dim]
")
console.print("[bold]MeshMonitor Sync Settings[/bold]\n")
console.print("[dim]Sync auto-responder triggers from MeshMonitor to avoid duplicate responses.[/dim]\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")
triggers_file = self.config.meshmonitor.triggers_file or "[dim]not set[/dim]"
table.add_row("1", "Enabled", self._status_icon(self.config.meshmonitor.enabled))
table.add_row("2", "Triggers File", triggers_file)
table.add_row("2", "MeshMonitor URL", self.config.meshmonitor.url or "[dim]not set[/dim]")
table.add_row("3", "Inject into Prompt", self._status_icon(self.config.meshmonitor.inject_into_prompt))
table.add_row("4", "View Triggers", "[dim]show loaded patterns[/dim]")
table.add_row("4", "Refresh Interval", f"{self.config.meshmonitor.refresh_interval}s")
table.add_row("5", "View Triggers", "[dim]Fetch and display[/dim]")
table.add_row("0", "Back", "")
console.print(table)
@ -639,74 +637,52 @@ class Configurator:
self.config.meshmonitor.enabled = not self.config.meshmonitor.enabled
self.modified = True
elif choice == 2:
value = Prompt.ask("Triggers file path", default=self.config.meshmonitor.triggers_file or "/data/triggers.json")
if value != self.config.meshmonitor.triggers_file:
self.config.meshmonitor.triggers_file = value
value = Prompt.ask("MeshMonitor URL (e.g., http://100.64.0.11:3333)",
default=self.config.meshmonitor.url)
if value != self.config.meshmonitor.url:
self.config.meshmonitor.url = value
self.modified = True
elif choice == 3:
self.config.meshmonitor.inject_into_prompt = not self.config.meshmonitor.inject_into_prompt
self.modified = True
elif choice == 4:
value = IntPrompt.ask("Refresh interval (seconds)", default=self.config.meshmonitor.refresh_interval)
if value != self.config.meshmonitor.refresh_interval:
self.config.meshmonitor.refresh_interval = value
self.modified = True
elif choice == 5:
self._view_meshmonitor_triggers()
def _view_meshmonitor_triggers(self) -> None:
"""Display loaded MeshMonitor trigger patterns."""
"""Fetch and display MeshMonitor triggers."""
self._clear()
console.print("[bold]MeshMonitor Triggers[/bold]
")
console.print("[bold]MeshMonitor Triggers[/bold]\n")
triggers_file = self.config.meshmonitor.triggers_file
if not triggers_file:
console.print("[yellow]No triggers file configured.[/yellow]")
input("
Press Enter to continue...")
if not self.config.meshmonitor.url:
console.print("[yellow]MeshMonitor URL not configured.[/yellow]")
input("\nPress Enter to continue...")
return
from pathlib import Path
import json
triggers_path = Path(triggers_file)
if not triggers_path.exists():
console.print(f"[yellow]Triggers file not found: {triggers_file}[/yellow]")
input("
Press Enter to continue...")
return
console.print(f"[dim]Fetching from {self.config.meshmonitor.url}...[/dim]\n")
try:
with open(triggers_path) as f:
data = json.load(f)
except json.JSONDecodeError as e:
console.print(f"[red]Invalid JSON in triggers file: {e}[/red]")
input("
Press Enter to continue...")
return
from ..meshmonitor import MeshMonitorSync
sync = MeshMonitorSync(self.config.meshmonitor.url)
count = sync.load()
if not data:
console.print("[dim]Triggers file is empty.[/dim]")
input("
Press Enter to continue...")
return
# Display triggers in a table
table = Table(box=box.ROUNDED, title="Loaded Triggers")
table.add_column("Command", style="cyan")
table.add_column("Pattern", style="white")
for name, pattern in data.items():
if isinstance(pattern, str):
table.add_row(name, pattern)
elif isinstance(pattern, dict) and "pattern" in pattern:
table.add_row(name, pattern["pattern"])
if count == 0:
if sync.last_error:
console.print(f"[red]Error: {sync.last_error}[/red]")
else:
console.print("[yellow]No triggers configured in MeshMonitor.[/yellow]")
else:
table.add_row(name, "[dim]complex[/dim]")
console.print(f"[green]Loaded {count} triggers:[/green]\n")
for trigger in sync.raw_triggers:
console.print(f" [cyan]{trigger}[/cyan]")
except Exception as e:
console.print(f"[red]Failed to fetch triggers: {e}[/red]")
console.print(table)
console.print(f"
[dim]Total: {len(data)} trigger(s)[/dim]")
input("
Press Enter to continue...")
input("\nPress Enter to continue...")
def _setup_wizard(self) -> None:
"""First-time setup wizard."""

View file

@ -75,16 +75,6 @@ class ContextConfig:
max_context_items: int = 20 # Max observations injected into LLM context
@dataclass
class MeshMonitorConfig:
"""MeshMonitor trigger sync settings."""
enabled: bool = False
triggers_file: str = ""
inject_into_prompt: bool = True
@dataclass
class CommandsConfig:
"""Command settings."""
@ -106,12 +96,24 @@ class LLMConfig:
timeout: int = 30
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. "
"You can passively observe recent mesh traffic when available. "
"If asked about mesh activity and no recent traffic is shown below, "
"say you haven't observed any traffic yet rather than claiming you lack access."
"YOUR COMMANDS (handled directly by you via DM):\n"
"!help — List available commands.\n"
"!ping — Connectivity test, responds with pong.\n"
"!status — Shows your version, uptime, user count, and message count.\n"
"!weather [location] — Weather lookup using Open-Meteo API.\n"
"!reset — Clears conversation history and memory.\n"
"!clear — Same as !reset.\n\n"
"YOUR ARCHITECTURE: Modular Python — pluggable LLM backends (OpenAI, Anthropic, "
"Google, local), per-user SQLite conversation history, rolling summary memory, "
"passive mesh context buffer (observes channel traffic), smart chunking for LoRa "
"message limits, prompt injection defense, advBBS filtering.\n\n"
"RESPONSE RULES:\n"
"- Keep responses VERY brief — under 200 characters total.\n"
"- Be concise but friendly. No markdown formatting.\n"
"- If asked about mesh activity and no recent traffic is shown, say you haven't "
"observed any yet.\n"
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
"- You are part of the freq51 mesh in the Twin Falls, Idaho area."
)
use_system_prompt: bool = True # Toggle to disable sending system prompt
web_search: bool = False # Enable web search (Open WebUI feature)
@ -143,6 +145,16 @@ class WeatherConfig:
wttr: WttrConfig = field(default_factory=WttrConfig)
@dataclass
class MeshMonitorConfig:
"""MeshMonitor trigger sync settings."""
enabled: bool = False
url: str = "" # e.g., http://100.64.0.11:3333
inject_into_prompt: bool = True # Tell LLM about MeshMonitor commands
refresh_interval: int = 300 # Seconds between refreshes
@dataclass
class Config:
"""Main configuration container."""

View file

@ -73,6 +73,10 @@ class MeshAI:
while self._running:
await asyncio.sleep(1)
# Periodic MeshMonitor refresh
if self.meshmonitor_sync:
self.meshmonitor_sync.maybe_refresh()
# Periodic cleanup
if time.time() - self._last_cleanup >= 3600:
await self.history.cleanup_expired()
@ -80,10 +84,6 @@ class MeshAI:
self.context.prune()
self._last_cleanup = time.time()
# Refresh MeshMonitor triggers if file changed
if self.meshmonitor_sync:
self.meshmonitor_sync.maybe_refresh()
async def stop(self) -> None:
"""Stop the bot."""
logger.info("Stopping MeshAI...")
@ -163,14 +163,17 @@ class MeshAI:
self.context = None
# MeshMonitor trigger sync
self.meshmonitor_sync = None
mm_cfg = self.config.meshmonitor
if mm_cfg.enabled and mm_cfg.triggers_file:
if mm_cfg.enabled and mm_cfg.url:
from .meshmonitor import MeshMonitorSync
self.meshmonitor_sync = MeshMonitorSync(
triggers_file=mm_cfg.triggers_file,
url=mm_cfg.url,
refresh_interval=mm_cfg.refresh_interval,
)
self.meshmonitor_sync.load()
count = self.meshmonitor_sync.load()
logger.info(f"MeshMonitor sync enabled, loaded {count} triggers")
else:
self.meshmonitor_sync = None
# Message router
self.router = MessageRouter(

View file

@ -1,179 +1,171 @@
"""Dynamic MeshMonitor trigger sync for MeshAI.
Reads MeshMonitor auto-responder triggers from a JSON file and converts
them to compiled regex patterns. The router uses these patterns to ignore
messages that MeshMonitor will handle.
Trigger file format (triggers.json):
{"triggers": ["weather {location}", "trivia", "ask {question}"]}
Or simple list:
["weather {location}", "trivia", "ask {question}"]
"""
import json
import logging
import re
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
def _trigger_to_regex(trigger: str) -> re.Pattern:
"""Convert a MeshMonitor trigger pattern to a compiled regex.
MeshMonitor trigger format:
- "weather {location}" -> matches "weather miami"
- "w {city},{state}" -> matches "w parkland,fl"
- "trivia" -> matches "trivia" exactly
- "ask {question}" -> matches "ask what is mesh"
- "{name:regex}" -> custom regex per param
Args:
trigger: MeshMonitor trigger pattern string
Returns:
Compiled regex pattern (case insensitive)
"""
pattern = trigger.strip()
# Temporarily replace {param} and {param:regex} blocks
param_blocks = []
def _save_param(m):
param_blocks.append(m.group(0))
return f"__PARAM_{len(param_blocks) - 1}__"
pattern = re.sub(r"\{[^}]+\}", _save_param, pattern)
# Escape remaining special chars
pattern = re.escape(pattern)
# Restore param blocks as regex groups
for i, block in enumerate(param_blocks):
placeholder = re.escape(f"__PARAM_{i}__")
# Check for custom regex: {name:regex}
custom_match = re.match(r"\{(\w+):(.+)\}", block)
if custom_match:
_name, custom_regex = custom_match.groups()
replacement = f"({custom_regex})"
else:
# Default: match one or more characters
replacement = r"(.+)"
pattern = pattern.replace(placeholder, replacement)
return re.compile(f"^{pattern}$", re.IGNORECASE)
class MeshMonitorSync:
"""Syncs MeshMonitor auto-responder triggers for MeshAI to ignore.
Reads trigger patterns from a JSON file and compiles them to regex.
Watches the file mtime so edits are picked up without restart.
"""
def __init__(self, triggers_file: str):
self._triggers_file = Path(triggers_file)
self._patterns: list[re.Pattern] = []
self._raw_triggers: list[str] = []
self._file_mtime: float = 0.0
@property
def trigger_list(self) -> list[str]:
"""Get raw trigger strings (for system prompt injection)."""
return self._raw_triggers
def load(self) -> int:
"""Load triggers from JSON file.
Returns:
Number of trigger patterns loaded
"""
if not self._triggers_file.exists():
logger.warning(f"Triggers file not found: {self._triggers_file}")
return 0
try:
self._file_mtime = self._triggers_file.stat().st_mtime
with open(self._triggers_file) as f:
data = json.load(f)
if isinstance(data, list):
raw_triggers = data
elif isinstance(data, dict):
raw_triggers = data.get("triggers", [])
else:
logger.warning(f"Unexpected triggers file format: {type(data)}")
return 0
self._raw_triggers = raw_triggers
self._compile_patterns(raw_triggers)
logger.info(
f"Loaded {len(self._patterns)} MeshMonitor trigger patterns "
f"from {self._triggers_file}"
)
return len(self._patterns)
except Exception as e:
logger.error(f"Failed to load triggers file: {e}")
return 0
def maybe_refresh(self) -> bool:
"""Reload triggers if the file has changed on disk.
Returns:
True if triggers were refreshed
"""
if not self._triggers_file.exists():
return False
mtime = self._triggers_file.stat().st_mtime
if mtime > self._file_mtime:
self.load()
return True
return False
def matches(self, text: str) -> bool:
"""Check if text matches any MeshMonitor trigger pattern.
Args:
text: Incoming message text (stripped)
Returns:
True if the message matches a MeshMonitor trigger
"""
for pattern in self._patterns:
if pattern.match(text):
logger.debug(
f"Message matches MeshMonitor trigger: "
f"{text[:40]}... -> {pattern.pattern}"
)
return True
return False
def _compile_patterns(self, raw_triggers: list[str]) -> None:
"""Compile trigger strings into regex patterns.
Handles comma-separated multi-patterns per trigger.
"""
patterns = []
for trigger in raw_triggers:
# Split multi-pattern triggers on comma
sub_patterns = [t.strip() for t in trigger.split(",")]
for sub in sub_patterns:
if not sub:
continue
try:
compiled = _trigger_to_regex(sub)
patterns.append(compiled)
except Exception as e:
logger.warning(
f"Failed to compile trigger pattern: {e}"
)
self._patterns = patterns
"""MeshMonitor trigger sync via HTTP."""
import json
import logging
import re
import time
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
def _trigger_to_regex(trigger: str) -> re.Pattern:
"""Convert MeshMonitor trigger pattern to compiled regex.
MeshMonitor patterns:
- !weather {location:.+} -> !weather (.+)
- !ping -> !ping
- !status -> !status
Args:
trigger: MeshMonitor trigger pattern
Returns:
Compiled regex pattern
"""
# Extract just the command part (before any {param} placeholders)
# Replace {name:pattern} with just the pattern for matching
pattern = re.sub(r"\{[^:}]+:([^}]+)\}", r"(\1)", trigger)
# Replace {name} (no pattern) with (.+)
pattern = re.sub(r"\{[^}]+\}", r"(.+)", pattern)
# Escape regex special chars except what we just inserted
# Actually, we need to be careful - just anchor it
pattern = "^" + pattern + "$"
return re.compile(pattern, re.IGNORECASE)
class MeshMonitorSync:
"""Sync auto-responder triggers from MeshMonitor HTTP API."""
def __init__(self, url: str, refresh_interval: int = 300):
"""Initialize MeshMonitor sync.
Args:
url: Base URL of MeshMonitor (e.g., http://100.64.0.11:3333)
refresh_interval: Seconds between refresh checks (default 5 minutes)
"""
self._url = url.rstrip("/")
self._refresh_interval = refresh_interval
self._patterns: list[re.Pattern] = []
self._raw_triggers: list[str] = []
self._last_refresh: float = 0.0
self._last_error: Optional[str] = None
@property
def raw_triggers(self) -> list[str]:
"""Get raw trigger patterns (for display)."""
return list(self._raw_triggers)
@property
def last_error(self) -> Optional[str]:
"""Get last error message if any."""
return self._last_error
def load(self) -> int:
"""Fetch triggers from MeshMonitor API.
Returns:
Number of triggers loaded
"""
endpoint = f"{self._url}/api/settings"
try:
req = Request(endpoint, headers={"Accept": "application/json"})
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
# autoResponderTriggers is a JSON string inside the response
triggers_json = data.get("autoResponderTriggers", "[]")
if isinstance(triggers_json, str):
triggers = json.loads(triggers_json)
else:
triggers = triggers_json
# Extract trigger patterns
self._raw_triggers = []
self._patterns = []
for item in triggers:
if isinstance(item, dict):
trigger_value = item.get("trigger", "")
else:
trigger_value = item
# Handle both string and list of strings
if isinstance(trigger_value, list):
trigger_list = trigger_value
elif isinstance(trigger_value, str) and trigger_value:
trigger_list = [trigger_value]
else:
continue
for trigger in trigger_list:
if not trigger:
continue
self._raw_triggers.append(trigger)
try:
self._patterns.append(_trigger_to_regex(trigger))
except re.error as e:
logger.warning(f"Invalid trigger pattern '{trigger}': {e}")
self._last_refresh = time.time()
self._last_error = None
logger.info(f"Loaded {len(self._patterns)} MeshMonitor triggers")
return len(self._patterns)
except HTTPError as e:
self._last_error = f"HTTP {e.code}: {e.reason}"
logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}")
return 0
except URLError as e:
self._last_error = f"Connection error: {e.reason}"
logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}")
return 0
except json.JSONDecodeError as e:
self._last_error = f"Invalid JSON: {e}"
logger.error(f"Failed to parse MeshMonitor response: {self._last_error}")
return 0
except Exception as e:
self._last_error = str(e)
logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}")
return 0
def maybe_refresh(self) -> bool:
"""Refresh triggers if interval has passed.
Returns:
True if refresh was performed
"""
if time.time() - self._last_refresh >= self._refresh_interval:
self.load()
return True
return False
def matches(self, text: str) -> bool:
"""Check if text matches any MeshMonitor trigger.
Args:
text: Message text to check
Returns:
True if MeshMonitor will handle this message
"""
text = text.strip()
for pattern in self._patterns:
if pattern.match(text):
return True
return False
def get_commands_summary(self) -> str:
"""Get a summary of MeshMonitor commands for prompt injection.
Returns:
Human-readable summary of available commands
"""
if not self._raw_triggers:
return ""
lines = ["MeshMonitor handles these commands (do not respond to them):"]
for trigger in self._raw_triggers:
lines.append(f" - {trigger}")
return "\n".join(lines)

View file

@ -104,14 +104,10 @@ class MessageRouter:
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
return False
# Ignore messages that match MeshMonitor auto-responder triggers
if self.meshmonitor_sync and message.text:
if self.meshmonitor_sync.matches(message.text.strip()):
logger.debug(
f"Ignoring DM from {message.sender_id}: "
f"matches MeshMonitor trigger"
)
return False
# Ignore messages that MeshMonitor will handle
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
return False
return True
@ -157,12 +153,48 @@ class MessageRouter:
# Get conversation history
history = await self.history.get_history_for_llm(message.sender_id)
# Get system prompt from config
system_prompt = ""
if getattr(self.config.llm, 'use_system_prompt', True):
system_prompt = self.config.llm.system_prompt
# Build system prompt in order: identity -> static -> meshmonitor -> context
# Inject mesh context if available
# 1. Dynamic identity from bot config
bot_name = self.config.bot.name or "MeshAI"
bot_owner = self.config.bot.owner or "Unknown"
identity = (
f"You are {bot_name}, an LLM-powered conversational assistant running on a "
f"Meshtastic mesh network. Your managing operator is {bot_owner}. "
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
f"IDENTITY: Your name is {bot_name}. You respond to DMs only. You connect "
f"to a Meshtastic node via TCP through meshtasticd.\n\n"
)
# 2. Static system prompt from config
static_prompt = ""
if getattr(self.config.llm, 'use_system_prompt', True):
static_prompt = self.config.llm.system_prompt
system_prompt = identity + static_prompt
# 3. MeshMonitor info (only when enabled)
if (
self.meshmonitor_sync
and self.config.meshmonitor.enabled
and self.config.meshmonitor.inject_into_prompt
):
meshmonitor_intro = (
"\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same "
"meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, "
"traceroutes, security scanning, and auto-responder commands. Its trigger "
"commands are listed below — if someone asks what commands are available, "
"mention both yours and MeshMonitor's. If someone asks where to get "
"MeshMonitor, direct them to github.com/Yeraze/meshmonitor"
)
system_prompt += meshmonitor_intro
commands_summary = self.meshmonitor_sync.get_commands_summary()
if commands_summary:
system_prompt += "\n\n" + commands_summary
# 4. Inject mesh context if available
if self.context:
max_items = getattr(self.config.context, 'max_context_items', 20)
context_block = self.context.get_context_block(max_items=max_items)
@ -176,23 +208,6 @@ class MessageRouter:
"\n\n[No recent mesh traffic observed yet.]"
)
# Inject MeshMonitor commands into prompt
if (
self.meshmonitor_sync
and getattr(self.config.meshmonitor, "inject_into_prompt", False)
and self.meshmonitor_sync.trigger_list
):
trigger_lines = ", ".join(
t for t in self.meshmonitor_sync.trigger_list
if t not in ("commands", "command")
)
system_prompt += (
"\n\nMESHMONITOR COMMANDS (handled by MeshMonitor, not you): "
+ trigger_lines
+ "\nIf someone asks what commands are available, mention these too."
)
try:
response = await self.llm.generate(
messages=history,