Add passive mesh context awareness — observe channel traffic, inject into LLM prompts

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 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-02-24 22:02:42 +00:00
commit 63a2caad37
7 changed files with 302 additions and 12 deletions

View file

@ -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

View file

@ -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: ""

View file

@ -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:

View file

@ -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)

154
meshai/context.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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,