feat(context): GUI control for chat-context retention (days) + live apply

- Config → Settings → Context: relabel the raw "Max Age (sec)" field to
  "Chat context retention (days)" (days<->seconds conversion, min 1,
  default 14). Governs the shared per-mesh chat memory window.
- Make PUT /api/config/context apply LIVE: MeshContext.update_settings()
  updates max_age/observe_channels/ignore_nodes in place; config_routes
  refreshes the running MeshContext via app.state.mesh_context (mirrors
  the existing _refresh_toggle_filter pattern) so retention changes take
  effect without a restart.

Tests: +tests/test_context_hot_reload.py (10); 0 new failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-04 01:03:22 +00:00
commit 18ba78cc04
5 changed files with 177 additions and 5 deletions

View file

@ -918,11 +918,11 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
here as general context knobs. */}
<div className="grid grid-cols-2 gap-4">
<NumberInput
label="Max Age (sec)"
value={data.max_age}
onChange={(v) => 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."
/>
<NumberInput
label="Max Context Items"

View file

@ -161,6 +161,30 @@ class MeshContext:
return "\n".join(lines)
def update_settings(
self,
*,
max_age: Optional[int] = None,
observe_channels: Optional[list] = None,
ignore_nodes: Optional[list] = None,
) -> 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."""

View file

@ -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

View file

@ -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()

View file

@ -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