chore(fleet): sync operator memory, hatch menu dialogs, and watchdog alerts

This commit is contained in:
operator
2026-10-07 00:25:51 +00:00
parent 0065d11e97
commit 34b0ef9fe2
38 changed files with 2205 additions and 444 deletions
+74 -8
View File
@@ -1,27 +1,76 @@
#!/usr/bin/env python3
"""
Side-chat to main-chat work siphon — monitor loop.
Side-chat to main-chat siphon — monitor loop (INTEGRATED).
Polls side chats for new messages, runs detection, siphons hits to main.
Changes vs original (integrator):
1. Timestamp plumbing (agent 2's open item): message["ts"] is parsed to
epoch seconds and passed as message_ts to detect(), enabling the
15-minute stale-suppression for COMPLETED. Unparseable/missing ts →
backward-compatible (detect proceeds).
2. Author plumbing (agent 3 absent): message["author"] is attached to
the hit as hit.author, so relays attribute the real author instead
of the thread's registered agent.
3. Flood control (agent 4 absent): COMPLETED hits are routed to the
digest buffer instead of individual main-chat relays. ALERT, BLOCKER,
DECISION, MILESTONE still relay individually via siphon().
4. Persistent dedup: every processed hit is marked siphoned (including
digested ones) so a restart never re-relays or re-digests.
This is the integration point for bl. In production:
- list_sidechats() calls muse-chat-api.py or the sidechat manager
- get_messages() reads thread messages via CDP
- post_to_main() sends via muse-chat-api.py send to main chat
For the prototype, all three are injectable (see tests).
- flush_digest() should be called on a schedule (e.g. every 30 min) and
its output posted to main chat once.
"""
import time
from typing import Callable, Dict, List
from datetime import datetime, timezone
from typing import Callable, Dict, List, Optional
from detect import detect, is_opted_out
from siphon import siphon, RateLimiter
from siphon import siphon, mark_siphoned, already_siphoned, RateLimiter
try:
from digest import get_buffer, flush_digest # noqa: F401 (re-export)
except ImportError: # pragma: no cover — digest module optional
get_buffer = None
def flush_digest():
return None
# Message shape: {"id": str, "text": str, "author": str, "ts": str}
Message = Dict[str, str]
# Categories that batch into the digest instead of relaying individually.
DIGESTED_CATEGORIES = {"COMPLETED"}
def _parse_ts(ts) -> Optional[float]:
"""Parse a message timestamp to epoch seconds. None if unparseable."""
if ts is None:
return None
if isinstance(ts, (int, float)):
return float(ts)
s = str(ts).strip()
if not s:
return None
# Epoch as string?
try:
return float(s)
except ValueError:
pass
# ISO-8601 (with optional Z suffix)?
try:
iso = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(iso)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except ValueError:
return None
def monitor_once(
list_sidechats: Callable[[], List[Dict[str, str]]],
@@ -41,6 +90,7 @@ def monitor_once(
"""
lim = limiter or RateLimiter()
new_marks = dict(watermarks)
digest = get_buffer() if get_buffer else None
for chat in list_sidechats():
tid = chat["id"]
@@ -64,8 +114,24 @@ def monitor_once(
# Update watermark to newest seen
new_marks[tid] = mid
hit = detect(text, tid, mid, min_confidence)
if hit:
# Persistent dedup first: never reprocess a seen message,
# even across restarts (marks are set for digested hits too).
if already_siphoned(mid):
continue
message_ts = _parse_ts(msg.get("ts"))
hit = detect(text, tid, mid, min_confidence,
message_ts=message_ts)
if hit is None:
continue
# Author plumbing: real author, never thread-owner-as-author.
hit.author = msg.get("author", "") or ""
if hit.category in DIGESTED_CATEGORIES and digest is not None:
digest.add(hit)
mark_siphoned(mid)
else:
siphon(hit, agent, post_to_main, lim)
return new_marks