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