meshai/meshai/context.py
Ubuntu 63a2caad37 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>
2026-02-24 22:02:42 +00:00

154 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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