mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
1172b9b67f
commit
f6540e893d
7 changed files with 251 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
179
meshai/meshmonitor.py
Normal 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
|
||||||
|
|
@ -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
1
triggers.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue