diff --git a/work/config.example.yaml b/work/config.example.yaml index 92de160..744fb45 100644 --- a/work/config.example.yaml +++ b/work/config.example.yaml @@ -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 === diff --git a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx index ccbf77e..9c00796 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx @@ -303,7 +303,7 @@ export default function MeshCoreConnection() { )} -

Channels to monitor (none selected = observe all)

+

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.

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." /> )} diff --git a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx index 736562c..9087aed 100644 --- a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx @@ -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." /> 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." /> )} diff --git a/work/docker-entrypoint.sh b/work/docker-entrypoint.sh index 0ed0f7d..6cc0851 100755 --- a/work/docker-entrypoint.sh +++ b/work/docker-entrypoint.sh @@ -47,7 +47,7 @@ context: enabled: true observe_channels: [] ignore_nodes: [] - max_age: 2592000 + max_age: 1209600 max_context_items: 20 llm: diff --git a/work/meshai/config.py b/work/meshai/config.py index da93c0b..7ea02d7 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -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 diff --git a/work/meshai/context.py b/work/meshai/context.py index d47f983..d04635c 100644 --- a/work/meshai/context.py +++ b/work/meshai/context.py @@ -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,14 +69,19 @@ 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) - if self._observe_channels is not None and channel not in self._observe_channels: - return + # 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 obs = MeshObservation( timestamp=time.time(), @@ -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: diff --git a/work/meshai/main.py b/work/meshai/main.py index 1f5d6b3..978c384 100644 --- a/work/meshai/main.py +++ b/work/meshai/main.py @@ -636,6 +636,7 @@ class MeshAI: text=message.text, channel=message.channel, is_dm=False, + transport=message.transport, ) # Check if we should respond diff --git a/work/meshai/router.py b/work/meshai/router.py index e6e674d..91a2416 100644 --- a/work/meshai/router.py +++ b/work/meshai/router.py @@ -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 diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index 45b76ef..666c129 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -42,10 +42,14 @@ 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: - name = idx_to_name.get(msg.channel) - if name is None or name not in cfg.observe_channels: - return False + # 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 return True diff --git a/work/tests/test_llm_scoping.py b/work/tests/test_llm_scoping.py new file mode 100644 index 0000000..9a647f0 --- /dev/null +++ b/work/tests/test_llm_scoping.py @@ -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}" + ) diff --git a/work/tests/test_meshcore_context_filter.py b/work/tests/test_meshcore_context_filter.py index 2b48059..49329fc 100644 --- a/work/tests/test_meshcore_context_filter.py +++ b/work/tests/test_meshcore_context_filter.py @@ -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