chore(fleet): sync operator memory, hatch menu dialogs, and watchdog alerts
This commit is contained in:
+49
-77
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user