mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
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:
parent
1e033316fb
commit
63a2caad37
7 changed files with 302 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
154
meshai/context.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue