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}"]
"""
"""MeshMonitor trigger sync via HTTP."""
import json
import logging
import re
from pathlib import Path
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 a MeshMonitor trigger pattern to a compiled regex.
"""Convert MeshMonitor trigger pattern to 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
MeshMonitor patterns:
- !weather {location:.+} -> !weather (.+)
- !ping -> !ping
- !status -> !status
Args:
trigger: MeshMonitor trigger pattern string
trigger: MeshMonitor trigger pattern
Returns:
Compiled regex pattern (case insensitive)
Compiled regex pattern
"""
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)
# 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:
"""Syncs MeshMonitor auto-responder triggers for MeshAI to ignore.
"""Sync auto-responder triggers from MeshMonitor HTTP API."""
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, url: str, refresh_interval: int = 300):
"""Initialize MeshMonitor sync.
def __init__(self, triggers_file: str):
self._triggers_file = Path(triggers_file)
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._file_mtime: float = 0.0
self._last_refresh: float = 0.0
self._last_error: Optional[str] = None
@property
def trigger_list(self) -> list[str]:
"""Get raw trigger strings (for system prompt injection)."""
return self._raw_triggers
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:
"""Load triggers from JSON file.
"""Fetch triggers from MeshMonitor API.
Returns:
Number of trigger patterns loaded
Number of triggers loaded
"""
if not self._triggers_file.exists():
logger.warning(f"Triggers file not found: {self._triggers_file}")
return 0
endpoint = f"{self._url}/api/settings"
try:
self._file_mtime = self._triggers_file.stat().st_mtime
req = Request(endpoint, headers={"Accept": "application/json"})
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
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", [])
# autoResponderTriggers is a JSON string inside the response
triggers_json = data.get("autoResponderTriggers", "[]")
if isinstance(triggers_json, str):
triggers = json.loads(triggers_json)
else:
logger.warning(f"Unexpected triggers file format: {type(data)}")
return 0
triggers = triggers_json
self._raw_triggers = raw_triggers
self._compile_patterns(raw_triggers)
# 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
logger.info(
f"Loaded {len(self._patterns)} MeshMonitor trigger patterns "
f"from {self._triggers_file}"
)
# 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:
logger.error(f"Failed to load triggers file: {e}")
self._last_error = str(e)
logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}")
return 0
def maybe_refresh(self) -> bool:
"""Reload triggers if the file has changed on disk.
"""Refresh triggers if interval has passed.
Returns:
True if triggers were refreshed
True if refresh was performed
"""
if not self._triggers_file.exists():
return False
mtime = self._triggers_file.stat().st_mtime
if mtime > self._file_mtime:
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 pattern.
"""Check if text matches any MeshMonitor trigger.
Args:
text: Incoming message text (stripped)
text: Message text to check
Returns:
True if the message matches a MeshMonitor trigger
True if MeshMonitor will handle this message
"""
text = text.strip()
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.
def get_commands_summary(self) -> str:
"""Get a summary of MeshMonitor commands for prompt injection.
Handles comma-separated multi-patterns per trigger.
Returns:
Human-readable summary of available commands
"""
patterns = []
if not self._raw_triggers:
return ""
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
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,