mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
494f4510ac
commit
5f66b69c9c
5 changed files with 296 additions and 298 deletions
|
|
@ -79,7 +79,8 @@ class Configurator:
|
||||||
table.add_row("7", "Context", f"{ctx_status} {self.config.context.max_context_items} items")
|
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}")
|
table.add_row("8", "Weather", f"{self.config.weather.primary}")
|
||||||
mm_status = self._status_icon(self.config.meshmonitor.enabled)
|
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]")
|
table.add_row("10", "Setup Wizard", "[dim]First-time setup[/dim]")
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
@ -89,13 +90,13 @@ class Configurator:
|
||||||
if self.modified:
|
if self.modified:
|
||||||
console.print("[yellow]* Unsaved changes[/yellow]")
|
console.print("[yellow]* Unsaved changes[/yellow]")
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[white]10. Save[/white] [dim]Save config, stay in menu[/dim]")
|
console.print("[white]11. Save[/white] [dim]Save config, stay in menu[/dim]")
|
||||||
console.print("[green]11. Save & Restart Bot[/green] [dim]Apply changes now[/dim]")
|
console.print("[green]12. 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. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]")
|
||||||
console.print("[white]13. Exit without Saving[/white]")
|
console.print("[white]14. Exit without Saving[/white]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
choice = IntPrompt.ask("Select option", default=11)
|
choice = IntPrompt.ask("Select option", default=12)
|
||||||
|
|
||||||
if choice == 1:
|
if choice == 1:
|
||||||
self._bot_settings()
|
self._bot_settings()
|
||||||
|
|
@ -606,26 +607,23 @@ class Configurator:
|
||||||
self.config.memory.summarize_threshold = value
|
self.config.memory.summarize_threshold = value
|
||||||
self.modified = True
|
self.modified = True
|
||||||
|
|
||||||
|
|
||||||
def _meshmonitor_settings(self) -> None:
|
def _meshmonitor_settings(self) -> None:
|
||||||
"""MeshMonitor sync settings submenu."""
|
"""MeshMonitor sync settings submenu."""
|
||||||
while True:
|
while True:
|
||||||
self._clear()
|
self._clear()
|
||||||
console.print("[bold]MeshMonitor Sync Settings[/bold]
|
console.print("[bold]MeshMonitor Sync Settings[/bold]\n")
|
||||||
")
|
console.print("[dim]Sync auto-responder triggers from MeshMonitor to avoid duplicate responses.[/dim]\n")
|
||||||
console.print("[dim]Auto-ignore messages that match MeshMonitor trigger patterns.[/dim]
|
|
||||||
")
|
|
||||||
|
|
||||||
table = Table(box=box.ROUNDED)
|
table = Table(box=box.ROUNDED)
|
||||||
table.add_column("Option", style="cyan", width=4)
|
table.add_column("Option", style="cyan", width=4)
|
||||||
table.add_column("Setting", style="white")
|
table.add_column("Setting", style="white")
|
||||||
table.add_column("Value", style="green")
|
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("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("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", "")
|
table.add_row("0", "Back", "")
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
@ -639,74 +637,52 @@ class Configurator:
|
||||||
self.config.meshmonitor.enabled = not self.config.meshmonitor.enabled
|
self.config.meshmonitor.enabled = not self.config.meshmonitor.enabled
|
||||||
self.modified = True
|
self.modified = True
|
||||||
elif choice == 2:
|
elif choice == 2:
|
||||||
value = Prompt.ask("Triggers file path", default=self.config.meshmonitor.triggers_file or "/data/triggers.json")
|
value = Prompt.ask("MeshMonitor URL (e.g., http://100.64.0.11:3333)",
|
||||||
if value != self.config.meshmonitor.triggers_file:
|
default=self.config.meshmonitor.url)
|
||||||
self.config.meshmonitor.triggers_file = value
|
if value != self.config.meshmonitor.url:
|
||||||
|
self.config.meshmonitor.url = value
|
||||||
self.modified = True
|
self.modified = True
|
||||||
elif choice == 3:
|
elif choice == 3:
|
||||||
self.config.meshmonitor.inject_into_prompt = not self.config.meshmonitor.inject_into_prompt
|
self.config.meshmonitor.inject_into_prompt = not self.config.meshmonitor.inject_into_prompt
|
||||||
self.modified = True
|
self.modified = True
|
||||||
elif choice == 4:
|
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()
|
self._view_meshmonitor_triggers()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _view_meshmonitor_triggers(self) -> None:
|
def _view_meshmonitor_triggers(self) -> None:
|
||||||
"""Display loaded MeshMonitor trigger patterns."""
|
"""Fetch and display MeshMonitor triggers."""
|
||||||
self._clear()
|
self._clear()
|
||||||
console.print("[bold]MeshMonitor Triggers[/bold]
|
console.print("[bold]MeshMonitor Triggers[/bold]\n")
|
||||||
")
|
|
||||||
|
|
||||||
triggers_file = self.config.meshmonitor.triggers_file
|
if not self.config.meshmonitor.url:
|
||||||
if not triggers_file:
|
console.print("[yellow]MeshMonitor URL not configured.[/yellow]")
|
||||||
console.print("[yellow]No triggers file configured.[/yellow]")
|
input("\nPress Enter to continue...")
|
||||||
input("
|
|
||||||
Press Enter to continue...")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
from pathlib import Path
|
console.print(f"[dim]Fetching from {self.config.meshmonitor.url}...[/dim]\n")
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(triggers_path) as f:
|
from ..meshmonitor import MeshMonitorSync
|
||||||
data = json.load(f)
|
sync = MeshMonitorSync(self.config.meshmonitor.url)
|
||||||
except json.JSONDecodeError as e:
|
count = sync.load()
|
||||||
console.print(f"[red]Invalid JSON in triggers file: {e}[/red]")
|
|
||||||
input("
|
|
||||||
Press Enter to continue...")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not data:
|
if count == 0:
|
||||||
console.print("[dim]Triggers file is empty.[/dim]")
|
if sync.last_error:
|
||||||
input("
|
console.print(f"[red]Error: {sync.last_error}[/red]")
|
||||||
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"])
|
|
||||||
else:
|
else:
|
||||||
table.add_row(name, "[dim]complex[/dim]")
|
console.print("[yellow]No triggers configured in MeshMonitor.[/yellow]")
|
||||||
|
else:
|
||||||
|
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)
|
input("\nPress Enter to continue...")
|
||||||
console.print(f"
|
|
||||||
[dim]Total: {len(data)} trigger(s)[/dim]")
|
|
||||||
input("
|
|
||||||
Press Enter to continue...")
|
|
||||||
|
|
||||||
def _setup_wizard(self) -> None:
|
def _setup_wizard(self) -> None:
|
||||||
"""First-time setup wizard."""
|
"""First-time setup wizard."""
|
||||||
|
|
|
||||||
|
|
@ -75,16 +75,6 @@ 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."""
|
||||||
|
|
@ -106,12 +96,24 @@ class LLMConfig:
|
||||||
timeout: int = 30
|
timeout: int = 30
|
||||||
|
|
||||||
system_prompt: str = (
|
system_prompt: str = (
|
||||||
"You are a helpful assistant on a Meshtastic mesh network. "
|
"YOUR COMMANDS (handled directly by you via DM):\n"
|
||||||
"Keep responses VERY brief - under 250 characters total. "
|
"!help — List available commands.\n"
|
||||||
"Be concise but friendly. No markdown formatting. "
|
"!ping — Connectivity test, responds with pong.\n"
|
||||||
"You can passively observe recent mesh traffic when available. "
|
"!status — Shows your version, uptime, user count, and message count.\n"
|
||||||
"If asked about mesh activity and no recent traffic is shown below, "
|
"!weather [location] — Weather lookup using Open-Meteo API.\n"
|
||||||
"say you haven't observed any traffic yet rather than claiming you lack access."
|
"!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
|
use_system_prompt: bool = True # Toggle to disable sending system prompt
|
||||||
web_search: bool = False # Enable web search (Open WebUI feature)
|
web_search: bool = False # Enable web search (Open WebUI feature)
|
||||||
|
|
@ -143,6 +145,16 @@ class WeatherConfig:
|
||||||
wttr: WttrConfig = field(default_factory=WttrConfig)
|
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
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
"""Main configuration container."""
|
"""Main configuration container."""
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ class MeshAI:
|
||||||
while self._running:
|
while self._running:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Periodic MeshMonitor refresh
|
||||||
|
if self.meshmonitor_sync:
|
||||||
|
self.meshmonitor_sync.maybe_refresh()
|
||||||
|
|
||||||
# Periodic cleanup
|
# Periodic cleanup
|
||||||
if time.time() - self._last_cleanup >= 3600:
|
if time.time() - self._last_cleanup >= 3600:
|
||||||
await self.history.cleanup_expired()
|
await self.history.cleanup_expired()
|
||||||
|
|
@ -80,10 +84,6 @@ 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...")
|
||||||
|
|
@ -163,14 +163,17 @@ class MeshAI:
|
||||||
self.context = None
|
self.context = None
|
||||||
|
|
||||||
# MeshMonitor trigger sync
|
# MeshMonitor trigger sync
|
||||||
self.meshmonitor_sync = None
|
|
||||||
mm_cfg = self.config.meshmonitor
|
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
|
from .meshmonitor import MeshMonitorSync
|
||||||
self.meshmonitor_sync = 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
|
# Message router
|
||||||
self.router = MessageRouter(
|
self.router = MessageRouter(
|
||||||
|
|
|
||||||
|
|
@ -1,179 +1,171 @@
|
||||||
"""Dynamic MeshMonitor trigger sync for MeshAI.
|
"""MeshMonitor trigger sync via HTTP."""
|
||||||
|
|
||||||
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 json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _trigger_to_regex(trigger: str) -> re.Pattern:
|
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:
|
MeshMonitor patterns:
|
||||||
- "weather {location}" -> matches "weather miami"
|
- !weather {location:.+} -> !weather (.+)
|
||||||
- "w {city},{state}" -> matches "w parkland,fl"
|
- !ping -> !ping
|
||||||
- "trivia" -> matches "trivia" exactly
|
- !status -> !status
|
||||||
- "ask {question}" -> matches "ask what is mesh"
|
|
||||||
- "{name:regex}" -> custom regex per param
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
trigger: MeshMonitor trigger pattern string
|
trigger: MeshMonitor trigger pattern
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Compiled regex pattern (case insensitive)
|
Compiled regex pattern
|
||||||
"""
|
"""
|
||||||
pattern = trigger.strip()
|
# Extract just the command part (before any {param} placeholders)
|
||||||
|
# Replace {name:pattern} with just the pattern for matching
|
||||||
# Temporarily replace {param} and {param:regex} blocks
|
pattern = re.sub(r"\{[^:}]+:([^}]+)\}", r"(\1)", trigger)
|
||||||
param_blocks = []
|
# Replace {name} (no pattern) with (.+)
|
||||||
def _save_param(m):
|
pattern = re.sub(r"\{[^}]+\}", r"(.+)", pattern)
|
||||||
param_blocks.append(m.group(0))
|
# Escape regex special chars except what we just inserted
|
||||||
return f"__PARAM_{len(param_blocks) - 1}__"
|
# Actually, we need to be careful - just anchor it
|
||||||
|
pattern = "^" + pattern + "$"
|
||||||
pattern = re.sub(r"\{[^}]+\}", _save_param, pattern)
|
return re.compile(pattern, re.IGNORECASE)
|
||||||
|
|
||||||
# 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:
|
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.
|
def __init__(self, url: str, refresh_interval: int = 300):
|
||||||
Watches the file mtime so edits are picked up without restart.
|
"""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("/")
|
||||||
def __init__(self, triggers_file: str):
|
self._refresh_interval = refresh_interval
|
||||||
self._triggers_file = Path(triggers_file)
|
|
||||||
self._patterns: list[re.Pattern] = []
|
self._patterns: list[re.Pattern] = []
|
||||||
self._raw_triggers: list[str] = []
|
self._raw_triggers: list[str] = []
|
||||||
self._file_mtime: float = 0.0
|
self._last_refresh: float = 0.0
|
||||||
|
self._last_error: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def trigger_list(self) -> list[str]:
|
def raw_triggers(self) -> list[str]:
|
||||||
"""Get raw trigger strings (for system prompt injection)."""
|
"""Get raw trigger patterns (for display)."""
|
||||||
return self._raw_triggers
|
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:
|
def load(self) -> int:
|
||||||
"""Load triggers from JSON file.
|
"""Fetch triggers from MeshMonitor API.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of trigger patterns loaded
|
Number of triggers loaded
|
||||||
"""
|
"""
|
||||||
if not self._triggers_file.exists():
|
endpoint = f"{self._url}/api/settings"
|
||||||
logger.warning(f"Triggers file not found: {self._triggers_file}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
try:
|
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:
|
# autoResponderTriggers is a JSON string inside the response
|
||||||
data = json.load(f)
|
triggers_json = data.get("autoResponderTriggers", "[]")
|
||||||
|
if isinstance(triggers_json, str):
|
||||||
if isinstance(data, list):
|
triggers = json.loads(triggers_json)
|
||||||
raw_triggers = data
|
|
||||||
elif isinstance(data, dict):
|
|
||||||
raw_triggers = data.get("triggers", [])
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unexpected triggers file format: {type(data)}")
|
triggers = triggers_json
|
||||||
return 0
|
|
||||||
|
|
||||||
self._raw_triggers = raw_triggers
|
# Extract trigger patterns
|
||||||
self._compile_patterns(raw_triggers)
|
self._raw_triggers = []
|
||||||
|
self._patterns = []
|
||||||
|
for item in triggers:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
trigger_value = item.get("trigger", "")
|
||||||
|
else:
|
||||||
|
trigger_value = item
|
||||||
|
|
||||||
logger.info(
|
# Handle both string and list of strings
|
||||||
f"Loaded {len(self._patterns)} MeshMonitor trigger patterns "
|
if isinstance(trigger_value, list):
|
||||||
f"from {self._triggers_file}"
|
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)
|
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:
|
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
|
return 0
|
||||||
|
|
||||||
def maybe_refresh(self) -> bool:
|
def maybe_refresh(self) -> bool:
|
||||||
"""Reload triggers if the file has changed on disk.
|
"""Refresh triggers if interval has passed.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if triggers were refreshed
|
True if refresh was performed
|
||||||
"""
|
"""
|
||||||
if not self._triggers_file.exists():
|
if time.time() - self._last_refresh >= self._refresh_interval:
|
||||||
return False
|
|
||||||
|
|
||||||
mtime = self._triggers_file.stat().st_mtime
|
|
||||||
if mtime > self._file_mtime:
|
|
||||||
self.load()
|
self.load()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def matches(self, text: str) -> bool:
|
def matches(self, text: str) -> bool:
|
||||||
"""Check if text matches any MeshMonitor trigger pattern.
|
"""Check if text matches any MeshMonitor trigger.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Incoming message text (stripped)
|
text: Message text to check
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the message matches a MeshMonitor trigger
|
True if MeshMonitor will handle this message
|
||||||
"""
|
"""
|
||||||
|
text = text.strip()
|
||||||
for pattern in self._patterns:
|
for pattern in self._patterns:
|
||||||
if pattern.match(text):
|
if pattern.match(text):
|
||||||
logger.debug(
|
|
||||||
f"Message matches MeshMonitor trigger: "
|
|
||||||
f"{text[:40]}... -> {pattern.pattern}"
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _compile_patterns(self, raw_triggers: list[str]) -> None:
|
def get_commands_summary(self) -> str:
|
||||||
"""Compile trigger strings into regex patterns.
|
"""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:
|
lines = ["MeshMonitor handles these commands (do not respond to them):"]
|
||||||
# Split multi-pattern triggers on comma
|
for trigger in self._raw_triggers:
|
||||||
sub_patterns = [t.strip() for t in trigger.split(",")]
|
lines.append(f" - {trigger}")
|
||||||
|
return "\n".join(lines)
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -104,13 +104,9 @@ 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
|
# Ignore messages that MeshMonitor will handle
|
||||||
if self.meshmonitor_sync and message.text:
|
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
|
||||||
if self.meshmonitor_sync.matches(message.text.strip()):
|
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
|
||||||
logger.debug(
|
|
||||||
f"Ignoring DM from {message.sender_id}: "
|
|
||||||
f"matches MeshMonitor trigger"
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
@ -157,12 +153,48 @@ class MessageRouter:
|
||||||
# Get conversation history
|
# Get conversation history
|
||||||
history = await self.history.get_history_for_llm(message.sender_id)
|
history = await self.history.get_history_for_llm(message.sender_id)
|
||||||
|
|
||||||
# Get system prompt from config
|
# Build system prompt in order: identity -> static -> meshmonitor -> context
|
||||||
system_prompt = ""
|
|
||||||
if getattr(self.config.llm, 'use_system_prompt', True):
|
|
||||||
system_prompt = self.config.llm.system_prompt
|
|
||||||
|
|
||||||
# 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:
|
if self.context:
|
||||||
max_items = getattr(self.config.context, 'max_context_items', 20)
|
max_items = getattr(self.config.context, 'max_context_items', 20)
|
||||||
context_block = self.context.get_context_block(max_items=max_items)
|
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.]"
|
"\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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue