mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(context): GUI control for chat-context retention (days) + live apply (#20)
- 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: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a191200a12
commit
70a0fd1657
5 changed files with 177 additions and 5 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
125
work/tests/test_context_hot_reload.py
Normal file
125
work/tests/test_context_hot_reload.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue