feat(meshcore): decouple per-mesh LLM DM gate + mesh-scoped chat context (#19)

- router.should_respond branches on message.transport: MeshCore DMs are
  governed solely by meshcore_context.respond_to_dms (enforced at the
  transport); Meshtastic solely by bot.respond_to_dms. No global gate.
- MeshObservation tagged per-transport; the LLM "recent traffic" block is
  scoped to the originating mesh (keyword override for the other mesh),
  labeled by mesh so the model knows which it is describing.
- MeshCore observe_channels is now opt-in (empty = observe none).
- Chat-context retention 30d -> 14d (both meshes).
- Meshtastic integer channel-index filter no longer misapplied to MeshCore
  observations (their channel is a companion slot index).
- Frontend: relabel DM toggles per-mesh ("Answer direct messages",
  Meshtastic-only / MeshCore-only), remove the false channel-mention
  tooltips, opt-in wording for MeshCore observe-channels.

Tests: +tests/test_llm_scoping.py (10), context-filter updated for opt-in;
0 new failures (34 pre-existing).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-03 18:39:26 -06:00 committed by GitHub
commit a191200a12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 243 additions and 28 deletions

View file

@ -45,7 +45,7 @@ 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_age: 1209600 # Max age in seconds (default 14 days)
max_context_items: 20 # Max observations injected into LLM context
# === LLM BACKEND ===

View file

@ -303,7 +303,7 @@ export default function MeshCoreConnection() {
</div>
)}
</div>
<p className="text-xs text-slate-600">Channels to monitor (none selected = observe all)</p>
<p className="text-xs text-slate-600">Choose which MeshCore channels feed MeshAI's context. Empty = none are watched pick channels to include their chatter in what the bot knows about the mesh. Leave busy/public channels out to keep them out of context.</p>
</div>
<ListInput
label="Ignore MeshCore Contacts"
@ -313,11 +313,10 @@ export default function MeshCoreConnection() {
info="Messages from these MeshCore contacts won't be included in passive context. Enter contact names or public-key prefixes."
/>
<Toggle
label="Respond to DMs"
label="Answer direct messages"
checked={!!mcContext.respond_to_dms}
onChange={(v) => setMcContext({ ...mcContext, respond_to_dms: v })}
helper="Reply when someone sends a MeshCore direct message"
info="When enabled, the bot responds to MeshCore direct messages. When disabled, it only responds to channel messages that mention its name."
helper="When on, MeshAI replies to MeshCore direct messages using the LLM. Applies to MeshCore only."
/>
</div>
)}

View file

@ -235,11 +235,10 @@ export default function MeshtasticConnection() {
info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
/>
<Toggle
label="Respond to DMs"
label="Answer direct messages"
checked={!!bot.respond_to_dms}
onChange={(v) => setBot({ ...bot, respond_to_dms: v })}
helper="Reply when someone sends a direct message"
info="When enabled, the bot responds to direct messages from any node. When disabled, the bot only responds to channel messages that mention its name."
helper="When on, MeshAI replies to Meshtastic direct messages using the LLM. Applies to Meshtastic only."
/>
</div>
)}

View file

@ -47,7 +47,7 @@ context:
enabled: true
observe_channels: []
ignore_nodes: []
max_age: 2592000
max_age: 1209600
max_context_items: 20
llm:

View file

@ -101,7 +101,7 @@ class ContextConfig:
use_tls: bool = False # Enable TLS for MQTT connection
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_age: int = 1_209_600 # 14 days in seconds
max_context_items: int = 20 # Max observations injected into LLM context
@ -109,7 +109,7 @@ class ContextConfig:
class MeshCoreContextConfig:
"""MeshCore passive-context / bot-behavior settings (MeshCore-native)."""
enable_passive_context: bool = True
observe_channels: list[str] = field(default_factory=list) # channel NAMES, empty = all
observe_channels: list[str] = field(default_factory=list) # channel NAMES, empty = none (opt-in): only listed channels feed context
ignore_contacts: list[str] = field(default_factory=list) # contact names or pubkey prefixes
respond_to_dms: bool = True

View file

@ -23,6 +23,7 @@ class MeshObservation:
channel: int
is_dm: bool
text: str
transport: str = "meshtastic"
class MeshContext:
@ -58,6 +59,7 @@ class MeshContext:
text: str,
channel: int,
is_dm: bool,
transport: str = "meshtastic",
) -> None:
"""Record an observed mesh message.
@ -67,12 +69,17 @@ class MeshContext:
text: Message text
channel: Channel index
is_dm: Whether this was a DM
transport: Origin transport ("meshtastic" or "meshcore")
"""
# Filter by node
if sender_id in self._ignore_nodes:
return
# Filter by channel (None = observe all)
# Filter by channel (None = observe all) — applies ONLY to Meshtastic
# observations because _observe_channels contains Meshtastic channel
# indices which have no relationship to MeshCore slot indices.
# MeshCore channel filtering is handled at the transport layer.
if transport == "meshtastic":
if self._observe_channels is not None and channel not in self._observe_channels:
return
@ -83,9 +90,10 @@ class MeshContext:
channel=channel,
is_dm=is_dm,
text=text,
transport=transport,
)
self._buffer.append(obs)
logger.debug(f"Observed: ch{channel} {sender_name}: {text[:40]}...")
logger.debug(f"Observed: ch{channel} {sender_name} [{transport}]: {text[:40]}...")
def prune(self) -> int:
"""Remove observations older than max_age.
@ -107,11 +115,14 @@ class MeshContext:
logger.info(f"Pruned {pruned} expired mesh observations ({len(self._buffer)} remaining)")
return pruned
def get_context_block(self, max_items: int = 20) -> str:
def get_context_block(self, max_items: int = 20, transport: Optional[str] = None) -> str:
"""Format recent observations as a context block for the LLM.
Args:
max_items: Maximum observations to include
transport: When provided, include only observations from this
transport ("meshtastic" or "meshcore"). When None, include
all observations (backward-compatible).
Returns:
Formatted context string, or empty string if no observations
@ -123,6 +134,8 @@ class MeshContext:
for obs in reversed(self._buffer):
if len(recent) >= max_items:
break
if transport is not None and obs.transport != transport:
continue
recent.append(obs)
if not recent:

View file

@ -636,6 +636,7 @@ class MeshAI:
text=message.text,
channel=message.channel,
is_dm=False,
transport=message.transport,
)
# Check if we should respond

View file

@ -341,7 +341,10 @@ class MessageRouter:
if not message.is_dm:
return False
if not self.config.bot.respond_to_dms:
# bot.respond_to_dms is the Meshtastic-only toggle; MeshCore DMs are
# governed solely by meshcore_context.respond_to_dms, enforced at the
# transport level before the message ever reaches here.
if message.transport != "meshcore" and not self.config.bot.respond_to_dms:
return False
# Ignore advBBS protocol and notification messages
@ -741,18 +744,31 @@ class MessageRouter:
if commands_summary:
system_prompt += "\n\n" + commands_summary
# 4. Inject mesh context if available
# 4. Inject mesh context if available, scoped to the originating mesh
# (with explicit override if the query names the other mesh).
if self.context:
max_items = getattr(self.config.context, 'max_context_items', 20)
context_block = self.context.get_context_block(max_items=max_items)
origin = getattr(message, "transport", "meshtastic")
# Determine target mesh: default to origin. Override if the query
# explicitly names the other mesh and NOT the origin mesh.
target = origin
q_lower = query.lower() if query else ""
names_meshcore = "meshcore" in q_lower or "mc mesh" in q_lower
names_meshtastic = "meshtastic" in q_lower or "mt mesh" in q_lower
if names_meshcore and not names_meshtastic:
target = "meshcore"
elif names_meshtastic and not names_meshcore:
target = "meshtastic"
context_block = self.context.get_context_block(max_items=max_items, transport=target)
mesh_label = "MeshCore" if target == "meshcore" else "Meshtastic"
if context_block:
system_prompt += (
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
f"\n\n--- Recent {mesh_label} mesh traffic (for context only, not messages to you) ---\n"
+ context_block
)
else:
system_prompt += (
"\n\n[No recent mesh traffic observed yet.]"
f"\n\n[No recent {mesh_label} mesh traffic observed yet.]"
)
# 5. Knowledge base retrieval

View file

@ -42,7 +42,11 @@ def mc_context_allows(cfg, msg, idx_to_name):
# channel (non-DM) message -> only relevant for passive context
if not cfg.enable_passive_context:
return False
if cfg.observe_channels:
# observe_channels is opt-in: empty = observe NONE; a channel message
# passes ONLY if observe_channels is non-empty AND the resolved channel
# name is in the list.
if not cfg.observe_channels:
return False
name = idx_to_name.get(msg.channel)
if name is None or name not in cfg.observe_channels:
return False

View file

@ -0,0 +1,178 @@
"""Tests for per-mesh LLM scoping changes.
Covers:
(a) should_respond DM gate decoupling (MeshCore vs Meshtastic)
(b) get_context_block(transport=...) per-mesh filtering
(c) Meshtastic channel-index filter is skipped for MeshCore observations
(d) ContextConfig.max_age default is 14 days (1_209_600 seconds)
"""
import sys
from unittest.mock import MagicMock
import pytest
# ---------------------------------------------------------------------------
# Stub optional heavy deps so meshai.router can be imported in this env.
# These stubs are set before any meshai.router import in this process;
# they are no-ops in production where the real packages are installed.
# ---------------------------------------------------------------------------
for _mod in ("openai", "aiosqlite", "anthropic", "google", "google.genai"):
sys.modules.setdefault(_mod, MagicMock())
from meshai.config import ( # noqa: E402
BotConfig,
Config,
ContextConfig,
MeshCoreContextConfig,
)
from meshai.connector import MeshMessage # noqa: E402
from meshai.context import MeshContext # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_message(transport="meshtastic", is_dm=True, text="hello"):
return MeshMessage(
sender_id="abc1",
sender_name="Alice",
text=text,
channel=0,
is_dm=is_dm,
transport=transport,
)
def _make_router(respond_to_dms: bool, meshcore_respond_to_dms: bool = True):
"""Build a minimal Router with mocked dependencies."""
from meshai.router import MessageRouter # noqa: PLC0415
config = Config()
config.bot = BotConfig(respond_to_dms=respond_to_dms)
config.meshcore_context = MeshCoreContextConfig(
respond_to_dms=meshcore_respond_to_dms
)
connector = MagicMock()
connector.my_node_id = "zzzz"
router = MessageRouter.__new__(MessageRouter)
router.config = config
router.connector = connector
router.meshmonitor_sync = None
router.continuations = MagicMock()
router.continuations.has_pending.return_value = False
return router
# ---------------------------------------------------------------------------
# (a) should_respond — DM gate decoupling
# ---------------------------------------------------------------------------
def test_meshcore_dm_passes_when_bot_respond_to_dms_false():
"""MeshCore DM should reach should_respond=True even if bot.respond_to_dms=False.
The global bot.respond_to_dms toggle is Meshtastic-only; MeshCore DMs
are pre-filtered at the transport by meshcore_context.respond_to_dms.
"""
router = _make_router(respond_to_dms=False, meshcore_respond_to_dms=True)
msg = _make_message(transport="meshcore", is_dm=True)
assert router.should_respond(msg) is True
def test_meshtastic_dm_blocked_when_bot_respond_to_dms_false():
"""Meshtastic DM must be blocked when bot.respond_to_dms=False."""
router = _make_router(respond_to_dms=False)
msg = _make_message(transport="meshtastic", is_dm=True)
assert router.should_respond(msg) is False
def test_meshtastic_dm_passes_when_bot_respond_to_dms_true():
"""Meshtastic DM passes when bot.respond_to_dms=True."""
router = _make_router(respond_to_dms=True)
msg = _make_message(transport="meshtastic", is_dm=True)
assert router.should_respond(msg) is True
# ---------------------------------------------------------------------------
# (b) get_context_block(transport=...) — per-mesh filtering
# ---------------------------------------------------------------------------
def test_get_context_block_no_transport_filter_returns_all():
ctx = MeshContext()
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
block = ctx.get_context_block(transport=None)
assert "hello from MT" in block
assert "hello from MC" in block
def test_get_context_block_transport_meshtastic_only():
ctx = MeshContext()
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
block = ctx.get_context_block(transport="meshtastic")
assert "hello from MT" in block
assert "hello from MC" not in block
def test_get_context_block_transport_meshcore_only():
ctx = MeshContext()
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
block = ctx.get_context_block(transport="meshcore")
assert "hello from MC" in block
assert "hello from MT" not in block
def test_get_context_block_empty_when_no_matching_transport():
ctx = MeshContext()
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
block = ctx.get_context_block(transport="meshcore")
assert block == ""
# ---------------------------------------------------------------------------
# (c) Meshtastic channel-index filter is skipped for MeshCore observations
# ---------------------------------------------------------------------------
def test_meshtastic_channel_filter_skipped_for_meshcore():
"""MeshCore observations bypass the Meshtastic _observe_channels index filter.
observe_channels=[0] would normally block channel=5 for Meshtastic, but a
MeshCore observation with channel=5 must still be recorded.
"""
ctx = MeshContext(observe_channels=[0]) # only Meshtastic ch0 allowed
# MeshCore channel slot 5: should NOT be filtered by Meshtastic indices
ctx.observe("MCNode", "mc1", "meshcore msg", channel=5, is_dm=False, transport="meshcore")
# Meshtastic ch5: should be filtered out
ctx.observe("MTNode", "mt1", "meshtastic msg", channel=5, is_dm=False, transport="meshtastic")
assert ctx.count == 1
block = ctx.get_context_block()
assert "meshcore msg" in block
assert "meshtastic msg" not in block
def test_meshtastic_channel_filter_still_applies_to_meshtastic():
"""Meshtastic channel-index filter still blocks unlisted Meshtastic channels."""
ctx = MeshContext(observe_channels=[0])
ctx.observe("MTNode", "mt1", "ch0 msg", channel=0, is_dm=False, transport="meshtastic")
ctx.observe("MTNode", "mt1", "ch1 msg", channel=1, is_dm=False, transport="meshtastic")
assert ctx.count == 1
block = ctx.get_context_block()
assert "ch0 msg" in block
assert "ch1 msg" not in block
# ---------------------------------------------------------------------------
# (d) ContextConfig.max_age default is 14 days
# ---------------------------------------------------------------------------
def test_context_config_max_age_default_is_14_days():
cfg = ContextConfig()
assert cfg.max_age == 1_209_600, (
f"Expected 1_209_600 (14 days) but got {cfg.max_age}"
)

View file

@ -39,9 +39,12 @@ def test_cfg_none_always_passes():
def test_defaults_pass_through():
# With opt-in semantics: empty observe_channels = observe NONE for channels.
# DMs still pass (respond_to_dms=True by default).
cfg = MeshCoreContextConfig() # empty lists, passive on, respond_to_dms on
idx_to_name = {1: "#general"}
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is True
# Channel: empty observe_channels now means NONE are observed (opt-in)
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is False
assert mc_context_allows(cfg, _dm(), idx_to_name) is True
@ -67,12 +70,14 @@ def test_ignore_contacts_matches_id_or_name():
def test_respond_to_dms_false_drops_dms_only():
cfg = MeshCoreContextConfig(respond_to_dms=False)
# observe_channels must be non-empty for channel msgs to be forwarded
# (opt-in: empty = none). Only DM behavior is being tested here.
cfg = MeshCoreContextConfig(respond_to_dms=False, observe_channels=["#general"])
idx_to_name = {1: "#general"}
# any DM dropped
assert mc_context_allows(cfg, _dm(), idx_to_name) is False
assert mc_context_allows(cfg, _dm(sender_id="zzzz", sender_name="Zed"), idx_to_name) is False
# channel msgs unaffected (passive still on)
# channel msgs unaffected (passive still on, channel in observe list)
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is True