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
+49 -77
View File
@@ -1,66 +1,30 @@
#!/usr/bin/env python3
"""
Side-chat to main-chat work siphon — siphon action.
Side-chat to main-chat work siphon — siphon action (INTEGRATED).
When detection fires, post a summary to main chat with:
- Category badge
- One-line summary (never full message text)
- Link back to the source side chat thread
- Confidence score (for transparency)
Changes vs original (integrator; agent 3 of 5 never delivered, so the
minimal reversible versions below stand in for its authorship/dedup work):
- format_siphon imported from detect (single definition; honest labeling
+ honest authorship live there).
- Deduplication is PERSISTENT: siphoned message IDs are stored as JSON
on disk (SIPHON_STATE_DIR or ~/.siphon-state/siphoned_ids.json) so a
restart can never re-relay. In-memory set kept as a fast path.
- mark_siphoned() writes through to disk on every call.
Safety:
Safety (unchanged):
- Rate limited (max N siphons per hour per thread)
- Never posts full message content
- Respects opt-out registry
- Deduplicates (same message_id never siphoned twice)
- Deduplicates (same message_id never siphoned twice, even across restarts)
"""
import json
import os
import time
from dataclasses import dataclass, field
from typing import Callable, Optional
from detect import SiphonHit, is_opted_out
# --- Follow-up modulation ---
#
# Wire the follow-up modulation table into the siphon so each hit gets
# the right follow-up policy:
# ALERT / BLOCKER / DECISION -> tracked, fast fuse for ALERT/BLOCKER
# COMPLETED / MILESTONE -> untracked (no nudge budget burned)
#
# modulate.py must be landed on bl before this runs (rollout step 1).
# If the import fails we degrade to the old behavior: post the summary
# with no follow-up tags (fail-closed toward visibility, not tracking).
try:
from modulate import for_siphon_hit, render_tags
_MODULATION_AVAILABLE = True
except ImportError: # pragma: no cover - deploy keeps modulate.py present
_MODULATION_AVAILABLE = False
for_siphon_hit = None
render_tags = None
def policy_for_hit(hit: SiphonHit):
"""Follow-up policy for a siphon hit, or None when untracked.
COMPLETED / MILESTONE hits return None (post the summary, create no
follow-up record). ALERT / BLOCKER / DECISION return a Policy whose
tags render into the canonical bracket vocabulary.
"""
if not _MODULATION_AVAILABLE:
return None
return for_siphon_hit(hit.category)
def is_tracked(hit: SiphonHit) -> bool:
"""True when this hit should create a follow-up record.
Callers that route tracked posts through dm.py --expect-reply (so a
dm_followup record is actually created) can use this to choose the
post path. Untracked hits post as plain summaries.
"""
return policy_for_hit(hit) is not None
from detect import SiphonHit, is_opted_out, format_siphon # noqa: F401 (re-export)
# --- Rate limiting ---
@@ -82,9 +46,37 @@ class RateLimiter:
return True
# --- Deduplication ---
# --- Deduplication (persistent) ---
_siphoned_ids: set = set()
_STATE_DIR = os.environ.get(
"SIPHON_STATE_DIR", os.path.expanduser("~/.siphon-state"))
DEDUP_FILE = os.path.join(_STATE_DIR, "siphoned_ids.json")
_DEDUP_MAX_IDS = 5000 # bound disk growth; oldest evicted first
def _load_siphoned() -> set:
try:
with open(DEDUP_FILE) as f:
data = json.load(f)
ids = data.get("ids", []) if isinstance(data, dict) else []
return set(ids)
except (OSError, ValueError):
return set()
def _save_siphoned(ids: set) -> None:
try:
os.makedirs(_STATE_DIR, exist_ok=True)
trimmed = sorted(ids)[-_DEDUP_MAX_IDS:]
tmp = DEDUP_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump({"ids": trimmed, "updated": time.time()}, f)
os.replace(tmp, DEDUP_FILE)
except OSError:
pass # dedup degrades to in-memory; never crash the relay on IO
_siphoned_ids: set = _load_siphoned()
def already_siphoned(message_id: str) -> bool:
@@ -93,6 +85,11 @@ def already_siphoned(message_id: str) -> bool:
def mark_siphoned(message_id: str):
_siphoned_ids.add(message_id)
_save_siphoned(_siphoned_ids)
def siphoned_count() -> int:
return len(_siphoned_ids)
# --- Siphon action ---
@@ -106,21 +103,6 @@ CATEGORY_EMOJI = {
}
def format_siphon(hit: SiphonHit, agent_name: str = "sidechat") -> str:
"""
Format a siphon message for main chat.
Never includes full message text — summary + link only.
"""
emoji = CATEGORY_EMOJI.get(hit.category, "📋")
thread_url = f"https://muse.ai/thread/{hit.thread_id}"
return (
f"{emoji} [{hit.category}] from {agent_name} side chat\n"
f"{hit.summary}\n"
f"→ {thread_url}\n"
f"(confidence {hit.confidence:.0%})"
)
def siphon(hit: SiphonHit,
agent_name: str,
post_to_main: Callable[[str], bool],
@@ -142,16 +124,6 @@ def siphon(hit: SiphonHit,
return False
text = format_siphon(hit, agent_name)
# Follow-up modulation: tracked hits (ALERT/BLOCKER/DECISION) get
# the canonical follow-up tags appended — [reply:expected],
# [reply:timeout=N], [reply:nudges=N], [reply:escalate=X], and
# [input:siphon] for the audit trail. Untracked hits
# (COMPLETED/MILESTONE) post as plain summaries.
policy = policy_for_hit(hit)
if policy is not None:
text = text + "\n" + render_tags(policy)
ok = post_to_main(text)
if ok:
mark_siphoned(hit.message_id)