diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx
index 8f08c7d..336bc9c 100644
--- a/work/dashboard-frontend/src/pages/Config.tsx
+++ b/work/dashboard-frontend/src/pages/Config.tsx
@@ -918,11 +918,11 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
here as general context knobs. */}
onChange({ ...data, max_age: v })}
- min={0}
- helper="Ignore messages older than this"
+ label="Chat context retention (days)"
+ value={Math.round((data.max_age ?? 1209600) / 86400)}
+ onChange={(v) => onChange({ ...data, max_age: v * 86400 })}
+ min={1}
+ helper="How long the bot remembers recent channel chat for context (applies to both meshes). Default 14 days."
/>
None:
+ """Apply config changes to the live store without a restart.
+
+ Mirrors the normalization rules of __init__ exactly:
+ - observe_channels: set(v) if v else None (empty list → None = observe all)
+ - ignore_nodes: set(v) if v else set() (empty list/None → empty set)
+ - max_age: stored as-is
+
+ Only parameters that are not None are updated; omit a parameter to
+ leave its current value unchanged.
+ """
+ if max_age is not None:
+ self._max_age = max_age
+ if observe_channels is not None:
+ self._observe_channels = set(observe_channels) if observe_channels else None
+ if ignore_nodes is not None:
+ self._ignore_nodes = set(ignore_nodes) if ignore_nodes else set()
+
@property
def count(self) -> int:
"""Number of observations in buffer."""
diff --git a/work/meshai/dashboard/api/config_routes.py b/work/meshai/dashboard/api/config_routes.py
index af85c64..660983b 100644
--- a/work/meshai/dashboard/api/config_routes.py
+++ b/work/meshai/dashboard/api/config_routes.py
@@ -172,6 +172,8 @@ async def update_config_section(section: str, request: Request):
setattr(request.app.state.config, section, new_value)
except Exception:
pass
+ if section == "context":
+ _refresh_mesh_context(request.app, new_value)
logger.info(
"Config section %r updated, restart_required=%s changed_keys=%s",
@@ -251,6 +253,26 @@ def _refresh_toggle_filter(app) -> bool:
return False
+def _refresh_mesh_context(app, new_ctx_cfg) -> bool:
+ """Best-effort live refresh of the running MeshContext after a context
+ config PUT. Returns True when the refresh actually fired, False if the
+ context instance is absent/None (passive context disabled, or early
+ startup). Never raises."""
+ try:
+ ctx = getattr(app.state, "mesh_context", None)
+ if ctx is None:
+ return False
+ ctx.update_settings(
+ max_age=new_ctx_cfg.max_age,
+ observe_channels=new_ctx_cfg.observe_channels,
+ ignore_nodes=new_ctx_cfg.ignore_nodes,
+ )
+ return True
+ except Exception:
+ logger.exception("mesh_context refresh failed")
+ return False
+
+
@router.post("/notifications/refresh-toggles")
async def refresh_toggles(request: Request):
"""Explicit refresh endpoint (kept for backwards-compat with the
diff --git a/work/meshai/dashboard/server.py b/work/meshai/dashboard/server.py
index 0f4a16c..071926d 100644
--- a/work/meshai/dashboard/server.py
+++ b/work/meshai/dashboard/server.py
@@ -123,6 +123,7 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster:
app.state.connector = meshai_instance.connector
app.state.bus = getattr(meshai_instance, "event_bus", None)
app.state.danger_correlator = getattr(meshai_instance, "danger_correlator", None)
+ app.state.mesh_context = getattr(meshai_instance, "context", None)
# Create broadcaster and attach to app state
broadcaster = DashboardBroadcaster()
diff --git a/work/tests/test_context_hot_reload.py b/work/tests/test_context_hot_reload.py
new file mode 100644
index 0000000..3e00ba6
--- /dev/null
+++ b/work/tests/test_context_hot_reload.py
@@ -0,0 +1,125 @@
+"""Tests for MeshContext.update_settings — live hot-reload without restart.
+
+Covers:
+ - update_settings applies max_age, observe_channels, ignore_nodes in place.
+ - Normalization mirrors __init__ exactly (empty list → None for
+ observe_channels; empty list/None → set() for ignore_nodes).
+ - An observation older than a newly-shortened max_age is pruned and
+ subsequently excluded by get_context_block.
+ - Omitting a parameter leaves the current value unchanged.
+"""
+
+import time
+
+from meshai.context import MeshContext, MeshObservation
+
+
+def _make_context(**kwargs) -> MeshContext:
+ return MeshContext(**kwargs)
+
+
+class TestUpdateSettingsNormalization:
+ """update_settings mirrors __init__ normalization rules."""
+
+ def test_max_age_updated(self):
+ ctx = _make_context(max_age=3600)
+ ctx.update_settings(max_age=7200)
+ assert ctx._max_age == 7200
+
+ def test_observe_channels_non_empty_becomes_set(self):
+ ctx = _make_context()
+ ctx.update_settings(observe_channels=[1, 2, 3])
+ assert ctx._observe_channels == {1, 2, 3}
+
+ def test_observe_channels_empty_list_becomes_none(self):
+ """Empty list → None (observe all), mirroring constructor behaviour."""
+ ctx = _make_context(observe_channels=[1, 2])
+ ctx.update_settings(observe_channels=[])
+ assert ctx._observe_channels is None
+
+ def test_observe_channels_none_arg_leaves_unchanged(self):
+ """Passing None as argument leaves _observe_channels unchanged."""
+ ctx = _make_context(observe_channels=[5])
+ ctx.update_settings(observe_channels=None)
+ assert ctx._observe_channels == {5}
+
+ def test_ignore_nodes_non_empty_becomes_set(self):
+ ctx = _make_context()
+ ctx.update_settings(ignore_nodes=["!abc", "!def"])
+ assert ctx._ignore_nodes == {"!abc", "!def"}
+
+ def test_ignore_nodes_empty_list_becomes_empty_set(self):
+ """Empty list → set(), mirroring constructor behaviour."""
+ ctx = _make_context(ignore_nodes=["!abc"])
+ ctx.update_settings(ignore_nodes=[])
+ assert ctx._ignore_nodes == set()
+
+ def test_ignore_nodes_none_arg_leaves_unchanged(self):
+ """Passing None as argument leaves _ignore_nodes unchanged."""
+ ctx = _make_context(ignore_nodes=["!abc"])
+ ctx.update_settings(ignore_nodes=None)
+ assert ctx._ignore_nodes == {"!abc"}
+
+ def test_omitted_params_unchanged(self):
+ """Only supplied params are modified; others stay as-is."""
+ ctx = _make_context(max_age=100, observe_channels=[3], ignore_nodes=["!x"])
+ ctx.update_settings(max_age=999)
+ assert ctx._max_age == 999
+ assert ctx._observe_channels == {3}
+ assert ctx._ignore_nodes == {"!x"}
+
+
+class TestPruneAfterMaxAgeShortened:
+ """After update_settings shortens max_age, prune() removes old entries
+ and get_context_block returns only the surviving observations."""
+
+ def _insert_obs(self, ctx: MeshContext, timestamp: float, text: str = "msg"):
+ """Bypass observe() to inject an observation with an arbitrary timestamp."""
+ obs = MeshObservation(
+ timestamp=timestamp,
+ sender_name="TestNode",
+ sender_id="!test1",
+ channel=0,
+ is_dm=False,
+ text=text,
+ )
+ ctx._buffer.append(obs)
+
+ def test_old_observation_pruned_after_max_age_shortened(self):
+ now = time.time()
+
+ # Start with a generous max_age (1 hour).
+ ctx = _make_context(max_age=3600)
+
+ # Insert one observation that is 30 minutes old.
+ self._insert_obs(ctx, now - 1800, text="old message")
+ assert ctx.count == 1
+ assert "old message" in ctx.get_context_block()
+
+ # Shorten max_age to 10 minutes — the 30-minute-old observation is now stale.
+ ctx.update_settings(max_age=600)
+ assert ctx._max_age == 600
+
+ # prune() is the documented mechanism to remove expired observations.
+ pruned = ctx.prune()
+ assert pruned == 1
+ assert ctx.count == 0
+ assert ctx.get_context_block() == ""
+
+ def test_recent_observation_survives_after_max_age_shortened(self):
+ now = time.time()
+
+ ctx = _make_context(max_age=3600)
+
+ # Insert one old and one recent observation.
+ self._insert_obs(ctx, now - 1800, text="old message")
+ self._insert_obs(ctx, now - 60, text="recent message")
+
+ # Shorten max_age to 10 minutes — only the old one is stale.
+ ctx.update_settings(max_age=600)
+ ctx.prune()
+
+ assert ctx.count == 1
+ block = ctx.get_context_block()
+ assert "recent message" in block
+ assert "old message" not in block