From 63a2caad37d972c80b55fcd708afa5017090121d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 24 Feb 2026 22:02:42 +0000 Subject: [PATCH] =?UTF-8?q?Add=20passive=20mesh=20context=20awareness=20?= =?UTF-8?q?=E2=80=94=20observe=20channel=20traffic,=20inject=20into=20LLM?= =?UTF-8?q?=20prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New context.py module: ring buffer (50K hard cap, ~25MB ceiling) passively records all channel broadcasts. Observations are formatted with relative timestamps and injected into the system prompt when generating LLM responses. Only public channel traffic is observed; DMs to the bot are excluded (already in per-user history). Bot's own node ID is auto-added to ignore list. Config: context.enabled, observe_channels, ignore_nodes, max_age, max_context_items TUI: new Context settings submenu (menu item 7) Hourly prune removes expired observations. Co-Authored-By: Claude Opus 4.6 --- config.example.yaml | 8 ++ docker-entrypoint.sh | 7 ++ meshai/cli/configurator.py | 89 ++++++++++++++++++--- meshai/config.py | 12 +++ meshai/context.py | 154 +++++++++++++++++++++++++++++++++++++ meshai/main.py | 31 ++++++++ meshai/router.py | 13 ++++ 7 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 meshai/context.py diff --git a/config.example.yaml b/config.example.yaml index a255db4..5da9aa1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -40,6 +40,14 @@ memory: window_size: 4 # Recent message pairs to keep in full summarize_threshold: 8 # Messages before re-summarizing +# === MESH CONTEXT === +context: + enabled: true # Observe channel traffic for LLM context + observe_channels: [] # Channel indices to observe (empty = all) + ignore_nodes: [] # Node IDs to exclude from observation + max_age: 2592000 # Max age in seconds (default 30 days) + max_context_items: 20 # Max observations injected into LLM context + # === LLM BACKEND === llm: backend: openai # openai | anthropic | google diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0313db3..fcfd7c8 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -43,6 +43,13 @@ memory: window_size: 4 summarize_threshold: 8 +context: + enabled: true + observe_channels: [] + ignore_nodes: [] + max_age: 2592000 + max_context_items: 20 + llm: backend: openai api_key: "" diff --git a/meshai/cli/configurator.py b/meshai/cli/configurator.py index 33471a9..ea03fc3 100644 --- a/meshai/cli/configurator.py +++ b/meshai/cli/configurator.py @@ -75,8 +75,10 @@ class Configurator: table.add_row("4", "Response Settings", f"{self.config.response.max_length}ch max") table.add_row("5", "History & Memory", f"{self.config.history.max_messages_per_user} msgs") table.add_row("6", "Commands", cmd_status) - table.add_row("7", "Weather", f"{self.config.weather.primary}") - table.add_row("8", "Setup Wizard", "[dim]First-time setup[/dim]") + ctx_status = self._status_icon(self.config.context.enabled) + 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("9", "Setup Wizard", "[dim]First-time setup[/dim]") console.print(table) console.print() @@ -85,13 +87,13 @@ class Configurator: if self.modified: console.print("[yellow]* Unsaved changes[/yellow]") console.print() - console.print("[white] 9. Save[/white] [dim]Save config, stay in menu[/dim]") - console.print("[green]10. Save & Restart Bot[/green] [dim]Apply changes now[/dim]") - console.print("[white]11. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]") - console.print("[white]12. Exit without Saving[/white]") + 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() - choice = IntPrompt.ask("Select option", default=10) + choice = IntPrompt.ask("Select option", default=11) if choice == 1: self._bot_settings() @@ -106,17 +108,19 @@ class Configurator: elif choice == 6: self._command_settings() elif choice == 7: - self._weather_settings() + self._context_settings() elif choice == 8: - self._setup_wizard() + self._weather_settings() elif choice == 9: - self._save_only() + self._setup_wizard() elif choice == 10: - self._save_and_restart() + self._save_only() elif choice == 11: + self._save_and_restart() + elif choice == 12: self._save_restart_exit() break - elif choice == 12: + elif choice == 13: break def _show_header(self) -> None: @@ -356,6 +360,67 @@ class Configurator: self.config.commands.prefix = value self.modified = True + def _context_settings(self) -> None: + """Mesh context settings submenu.""" + while True: + self._clear() + console.print("[bold]Mesh Context Settings[/bold]\n") + console.print("[dim]Passively observes channel traffic to give the LLM situational awareness.[/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") + + channels = self.config.context.observe_channels + ch_display = ", ".join(str(c) for c in channels) if channels else "[dim]all[/dim]" + nodes = self.config.context.ignore_nodes + node_display = ", ".join(nodes) if nodes else "[dim]none[/dim]" + age_days = self.config.context.max_age // 86400 + + table.add_row("1", "Enabled", self._status_icon(self.config.context.enabled)) + table.add_row("2", "Observe Channels", ch_display) + table.add_row("3", "Ignore Nodes", node_display) + table.add_row("4", "Max Age", f"{age_days}d") + table.add_row("5", "Max Context Items", str(self.config.context.max_context_items)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + self.config.context.enabled = not self.config.context.enabled + self.modified = True + elif choice == 2: + console.print("\n[dim]Enter channel indices separated by commas, or leave empty for all.[/dim]") + value = Prompt.ask("Channels", default=", ".join(str(c) for c in channels)) + parsed = [int(x.strip()) for x in value.split(",") if x.strip().isdigit()] if value.strip() else [] + if parsed != self.config.context.observe_channels: + self.config.context.observe_channels = parsed + self.modified = True + elif choice == 3: + console.print("\n[dim]Enter node IDs separated by commas, or leave empty for none.[/dim]") + value = Prompt.ask("Node IDs", default=", ".join(nodes)) + parsed = [x.strip() for x in value.split(",") if x.strip()] if value.strip() else [] + if parsed != self.config.context.ignore_nodes: + self.config.context.ignore_nodes = parsed + self.modified = True + elif choice == 4: + value = IntPrompt.ask("Max age (days)", default=age_days) + seconds = value * 86400 + if seconds != self.config.context.max_age: + self.config.context.max_age = seconds + self.modified = True + elif choice == 5: + value = IntPrompt.ask("Max context items", default=self.config.context.max_context_items) + if value != self.config.context.max_context_items: + self.config.context.max_context_items = value + self.modified = True + def _weather_settings(self) -> None: """Weather settings submenu.""" while True: diff --git a/meshai/config.py b/meshai/config.py index d9e491e..fd0bb68 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -64,6 +64,17 @@ class MemoryConfig: summarize_threshold: int = 8 # Messages before re-summarizing +@dataclass +class ContextConfig: + """Passive mesh context settings.""" + + enabled: bool = True + observe_channels: list[int] = field(default_factory=list) # Empty = all channels + ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore + max_age: int = 2_592_000 # 30 days in seconds + max_context_items: int = 20 # Max observations injected into LLM context + + @dataclass class CommandsConfig: """Command settings.""" @@ -128,6 +139,7 @@ class Config: response: ResponseConfig = field(default_factory=ResponseConfig) history: HistoryConfig = field(default_factory=HistoryConfig) memory: MemoryConfig = field(default_factory=MemoryConfig) + context: ContextConfig = field(default_factory=ContextConfig) commands: CommandsConfig = field(default_factory=CommandsConfig) llm: LLMConfig = field(default_factory=LLMConfig) weather: WeatherConfig = field(default_factory=WeatherConfig) diff --git a/meshai/context.py b/meshai/context.py new file mode 100644 index 0000000..d47f983 --- /dev/null +++ b/meshai/context.py @@ -0,0 +1,154 @@ +"""Passive mesh traffic context buffer.""" + +import logging +import time +from collections import deque +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + +# Hard safety cap — prevents unbounded memory if a node loops. +# 50,000 entries × ~500 bytes = ~25 MB absolute ceiling. +_HARD_CAP = 50_000 + + +@dataclass(frozen=True) +class MeshObservation: + """A single observed mesh message.""" + + timestamp: float + sender_name: str + sender_id: str + channel: int + is_dm: bool + text: str + + +class MeshContext: + """Rolling buffer of recent mesh traffic for LLM context injection. + + Passively observes all mesh messages (channels, DMs, BBS notifications) + and makes them available as context when generating LLM responses. + Observations older than max_age are pruned periodically. + """ + + def __init__( + self, + observe_channels: Optional[list[int]] = None, + ignore_nodes: Optional[list[str]] = None, + max_age: int = 2_592_000, + ): + """Initialize context buffer. + + Args: + observe_channels: Channel indices to observe (None = all) + ignore_nodes: Node IDs to exclude (e.g., own bot ID) + max_age: Max age in seconds for observations (default 30 days) + """ + self._buffer: deque[MeshObservation] = deque(maxlen=_HARD_CAP) + self._observe_channels = set(observe_channels) if observe_channels else None + self._ignore_nodes = set(ignore_nodes) if ignore_nodes else set() + self._max_age = max_age + + def observe( + self, + sender_name: str, + sender_id: str, + text: str, + channel: int, + is_dm: bool, + ) -> None: + """Record an observed mesh message. + + Args: + sender_name: Sender's display name + sender_id: Sender's node ID + text: Message text + channel: Channel index + is_dm: Whether this was a DM + """ + # Filter by node + if sender_id in self._ignore_nodes: + return + + # Filter by channel (None = observe all) + if self._observe_channels is not None and channel not in self._observe_channels: + return + + obs = MeshObservation( + timestamp=time.time(), + sender_name=sender_name, + sender_id=sender_id, + channel=channel, + is_dm=is_dm, + text=text, + ) + self._buffer.append(obs) + logger.debug(f"Observed: ch{channel} {sender_name}: {text[:40]}...") + + def prune(self) -> int: + """Remove observations older than max_age. + + Call this periodically (e.g., hourly from the main loop). + + Returns: + Number of observations pruned + """ + cutoff = time.time() - self._max_age + before = len(self._buffer) + + # deque is sorted by time (append-only), so pop from the left + while self._buffer and self._buffer[0].timestamp < cutoff: + self._buffer.popleft() + + pruned = before - len(self._buffer) + if pruned > 0: + logger.info(f"Pruned {pruned} expired mesh observations ({len(self._buffer)} remaining)") + return pruned + + def get_context_block(self, max_items: int = 20) -> str: + """Format recent observations as a context block for the LLM. + + Args: + max_items: Maximum observations to include + + Returns: + Formatted context string, or empty string if no observations + """ + now = time.time() + + # Take the most recent max_items (newest first, then reverse) + recent = [] + for obs in reversed(self._buffer): + if len(recent) >= max_items: + break + recent.append(obs) + + if not recent: + return "" + + # Reverse back to chronological + recent.reverse() + + lines = [] + for obs in recent: + age_mins = int((now - obs.timestamp) / 60) + if age_mins < 1: + age_str = "just now" + elif age_mins < 60: + age_str = f"{age_mins}m ago" + elif age_mins < 1440: + age_str = f"{age_mins // 60}h{age_mins % 60}m ago" + else: + age_str = f"{age_mins // 1440}d ago" + + source = "DM" if obs.is_dm else f"ch{obs.channel}" + lines.append(f"[{age_str}] [{source}] {obs.sender_name}: {obs.text}") + + return "\n".join(lines) + + @property + def count(self) -> int: + """Number of observations in buffer.""" + return len(self._buffer) diff --git a/meshai/main.py b/meshai/main.py index e04bf4f..69b7773 100644 --- a/meshai/main.py +++ b/meshai/main.py @@ -18,6 +18,7 @@ from .commands.dispatcher import create_dispatcher from .commands.status import set_start_time from .config import Config, load_config from .connector import MeshConnector, MeshMessage +from .context import MeshContext from .history import ConversationHistory from .memory import ConversationSummary from .responder import Responder @@ -35,6 +36,7 @@ class MeshAI: self.history: Optional[ConversationHistory] = None self.dispatcher: Optional[CommandDispatcher] = None self.llm: Optional[LLMBackend] = None + self.context: Optional[MeshContext] = None self.router: Optional[MessageRouter] = None self.responder: Optional[Responder] = None self._running = False @@ -53,6 +55,10 @@ class MeshAI: self.connector.connect() self.connector.set_message_callback(self._on_message, asyncio.get_event_loop()) + # Add own node ID to context ignore list + if self.context and self.connector.my_node_id: + self.context._ignore_nodes.add(self.connector.my_node_id) + self._running = True self._loop = asyncio.get_event_loop() self._last_cleanup = time.time() @@ -69,6 +75,8 @@ class MeshAI: # Periodic cleanup if time.time() - self._last_cleanup >= 3600: await self.history.cleanup_expired() + if self.context: + self.context.prune() self._last_cleanup = time.time() async def stop(self) -> None: @@ -137,9 +145,22 @@ class MeshAI: # Meshtastic connector self.connector = MeshConnector(self.config.connection) + # Passive mesh context buffer + ctx_cfg = self.config.context + if ctx_cfg.enabled: + self.context = MeshContext( + observe_channels=ctx_cfg.observe_channels or None, + ignore_nodes=ctx_cfg.ignore_nodes or None, + max_age=ctx_cfg.max_age, + ) + logger.info("Mesh context buffer enabled") + else: + self.context = None + # Message router self.router = MessageRouter( self.config, self.connector, self.history, self.dispatcher, self.llm, + context=self.context, ) # Responder @@ -148,6 +169,16 @@ class MeshAI: async def _on_message(self, message: MeshMessage) -> None: """Handle incoming message.""" try: + # Passively observe channel broadcasts for context (before filtering) + if self.context and not message.is_dm and message.text: + self.context.observe( + sender_name=message.sender_name, + sender_id=message.sender_id, + text=message.text, + channel=message.channel, + is_dm=False, + ) + # Check if we should respond if not self.router.should_respond(message): return diff --git a/meshai/router.py b/meshai/router.py index 018071b..d2ec57b 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -10,6 +10,7 @@ from .backends.base import LLMBackend from .commands import CommandContext, CommandDispatcher from .config import Config from .connector import MeshConnector, MeshMessage +from .context import MeshContext from .history import ConversationHistory logger = logging.getLogger(__name__) @@ -61,12 +62,14 @@ class MessageRouter: history: ConversationHistory, dispatcher: CommandDispatcher, llm_backend: LLMBackend, + context: MeshContext = None, ): self.config = config self.connector = connector self.history = history self.dispatcher = dispatcher self.llm = llm_backend + self.context = context def should_respond(self, message: MeshMessage) -> bool: @@ -147,6 +150,16 @@ class MessageRouter: if getattr(self.config.llm, 'use_system_prompt', True): system_prompt = self.config.llm.system_prompt + # 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) + if context_block: + system_prompt += ( + "\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n" + + context_block + ) + try: response = await self.llm.generate( messages=history,