feat(hybrid-gateway): integrate muse-cli with Cloudflare netns isolation, symmetric sidechat routing, and 646-pip sync unblock
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Side-chat to main-chat work siphon — siphon action.
|
||||
|
||||
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)
|
||||
|
||||
Safety:
|
||||
- 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)
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- Rate limiting ---
|
||||
|
||||
@dataclass
|
||||
class RateLimiter:
|
||||
max_per_hour: int = 5
|
||||
_timestamps: dict = field(default_factory=dict) # thread_id -> [ts, ...]
|
||||
|
||||
def allow(self, thread_id: str) -> bool:
|
||||
now = time.time()
|
||||
stamps = self._timestamps.get(thread_id, [])
|
||||
# Prune older than 1 hour
|
||||
stamps = [s for s in stamps if now - s < 3600]
|
||||
if len(stamps) >= self.max_per_hour:
|
||||
return False
|
||||
stamps.append(now)
|
||||
self._timestamps[thread_id] = stamps
|
||||
return True
|
||||
|
||||
|
||||
# --- Deduplication ---
|
||||
|
||||
_siphoned_ids: set = set()
|
||||
|
||||
|
||||
def already_siphoned(message_id: str) -> bool:
|
||||
return message_id in _siphoned_ids
|
||||
|
||||
|
||||
def mark_siphoned(message_id: str):
|
||||
_siphoned_ids.add(message_id)
|
||||
|
||||
|
||||
# --- Siphon action ---
|
||||
|
||||
CATEGORY_EMOJI = {
|
||||
"COMPLETED": "✅",
|
||||
"BLOCKER": "🚧",
|
||||
"DECISION": "❓",
|
||||
"ALERT": "🚨",
|
||||
"MILESTONE": "🎯",
|
||||
}
|
||||
|
||||
|
||||
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],
|
||||
limiter: Optional[RateLimiter] = None) -> bool:
|
||||
"""
|
||||
Execute the siphon: post summary to main chat.
|
||||
|
||||
post_to_main: callable that posts text to main chat, returns True on success.
|
||||
Returns True if siphoned, False if suppressed.
|
||||
"""
|
||||
# Safety checks
|
||||
if is_opted_out(hit.thread_id):
|
||||
return False
|
||||
if already_siphoned(hit.message_id):
|
||||
return False
|
||||
|
||||
lim = limiter or RateLimiter()
|
||||
if not lim.allow(hit.thread_id):
|
||||
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)
|
||||
return ok
|
||||
Reference in New Issue
Block a user