feat: Dynamic MeshMonitor trigger sync — auto-ignore MeshMonitor commands

Add MeshMonitorSync class that reads trigger patterns from a JSON file
and compiles them to regex. The router checks incoming messages against
these patterns and ignores messages that MeshMonitor will handle.

- New meshai/meshmonitor.py: Pattern compilation and file watching
- MeshMonitorConfig dataclass with enabled, triggers_file, inject_into_prompt
- Router integration: ignore matching messages, inject commands into prompt
- Main loop refresh: watch triggers file for changes without restart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root 2026-05-03 03:20:23 +00:00
commit f6540e893d
7 changed files with 251 additions and 0 deletions

View file

@ -66,3 +66,9 @@ weather:
primary: openmeteo # openmeteo | wttr | llm primary: openmeteo # openmeteo | wttr | llm
fallback: llm # openmeteo | wttr | llm | none fallback: llm # openmeteo | wttr | llm | none
default_location: "" # Default location for !weather (optional) default_location: "" # Default location for !weather (optional)
# === MESHMONITOR INTEGRATION ===
meshmonitor:
enabled: false
triggers_file: /data/triggers.json
inject_into_prompt: true

View file

@ -61,10 +61,20 @@ llm:
Keep responses VERY brief - under 250 characters total. Keep responses VERY brief - under 250 characters total.
Be concise but friendly. No markdown formatting. Be concise but friendly. No markdown formatting.
google_grounding: false google_grounding: false
meshmonitor:
enabled: false
triggers_file: /data/triggers.json
inject_into_prompt: true
EOF EOF
echo "Default config created. Configure via http://localhost:7682" echo "Default config created. Configure via http://localhost:7682"
fi fi
# Create triggers.json if missing
if [ ! -f "/data/triggers.json" ]; then
echo '{"triggers": []}' > /data/triggers.json
fi
# Start ttyd for web-based config access # Start ttyd for web-based config access
echo "Starting web config interface on port 7682..." echo "Starting web config interface on port 7682..."
ttyd -W -p 7682 \ ttyd -W -p 7682 \

View file

@ -75,6 +75,16 @@ class ContextConfig:
max_context_items: int = 20 # Max observations injected into LLM context 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 @dataclass
class CommandsConfig: class CommandsConfig:
"""Command settings.""" """Command settings."""
@ -146,6 +156,7 @@ class Config:
commands: CommandsConfig = field(default_factory=CommandsConfig) commands: CommandsConfig = field(default_factory=CommandsConfig)
llm: LLMConfig = field(default_factory=LLMConfig) llm: LLMConfig = field(default_factory=LLMConfig)
weather: WeatherConfig = field(default_factory=WeatherConfig) weather: WeatherConfig = field(default_factory=WeatherConfig)
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
_config_path: Optional[Path] = field(default=None, repr=False) _config_path: Optional[Path] = field(default=None, repr=False)

View file

@ -37,6 +37,7 @@ class MeshAI:
self.dispatcher: Optional[CommandDispatcher] = None self.dispatcher: Optional[CommandDispatcher] = None
self.llm: Optional[LLMBackend] = None self.llm: Optional[LLMBackend] = None
self.context: Optional[MeshContext] = None self.context: Optional[MeshContext] = None
self.meshmonitor_sync = None
self.router: Optional[MessageRouter] = None self.router: Optional[MessageRouter] = None
self.responder: Optional[Responder] = None self.responder: Optional[Responder] = None
self._running = False self._running = False
@ -79,6 +80,10 @@ class MeshAI:
self.context.prune() self.context.prune()
self._last_cleanup = time.time() 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: async def stop(self) -> None:
"""Stop the bot.""" """Stop the bot."""
logger.info("Stopping MeshAI...") logger.info("Stopping MeshAI...")
@ -157,10 +162,21 @@ class MeshAI:
else: else:
self.context = None self.context = None
# MeshMonitor trigger sync
self.meshmonitor_sync = None
mm_cfg = self.config.meshmonitor
if mm_cfg.enabled and mm_cfg.triggers_file:
from .meshmonitor import MeshMonitorSync
self.meshmonitor_sync = MeshMonitorSync(
triggers_file=mm_cfg.triggers_file,
)
self.meshmonitor_sync.load()
# Message router # Message router
self.router = MessageRouter( self.router = MessageRouter(
self.config, self.connector, self.history, self.dispatcher, self.llm, self.config, self.connector, self.history, self.dispatcher, self.llm,
context=self.context, context=self.context,
meshmonitor_sync=self.meshmonitor_sync,
) )
# Responder # Responder

179
meshai/meshmonitor.py Normal file
View file

@ -0,0 +1,179 @@
"""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

View file

@ -64,6 +64,7 @@ class MessageRouter:
dispatcher: CommandDispatcher, dispatcher: CommandDispatcher,
llm_backend: LLMBackend, llm_backend: LLMBackend,
context: MeshContext = None, context: MeshContext = None,
meshmonitor_sync=None,
): ):
self.config = config self.config = config
self.connector = connector self.connector = connector
@ -71,6 +72,7 @@ class MessageRouter:
self.dispatcher = dispatcher self.dispatcher = dispatcher
self.llm = llm_backend self.llm = llm_backend
self.context = context self.context = context
self.meshmonitor_sync = meshmonitor_sync
def should_respond(self, message: MeshMessage) -> bool: def should_respond(self, message: MeshMessage) -> bool:
@ -102,6 +104,15 @@ class MessageRouter:
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...") logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
return False 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
return True return True
async def route(self, message: MeshMessage) -> RouteResult: async def route(self, message: MeshMessage) -> RouteResult:
@ -165,6 +176,23 @@ class MessageRouter:
"\n\n[No recent mesh traffic observed yet.]" "\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: try:
response = await self.llm.generate( response = await self.llm.generate(
messages=history, messages=history,

1
triggers.json Normal file
View file

@ -0,0 +1 @@
{}