mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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>
154 lines
4.5 KiB
Python
154 lines
4.5 KiB
Python
"""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)
|