4.9 KiB
Plan: (no topic) Thread Routing
Date: 2026-05-14 Status: In Progress — plugin being implemented Priority: High
Problem
When Alex sends a message in Zulip's (no topic) thread (the default unnamed thread), it usually means one of two things:
- He missed the correct topic — it should go to the most recent active thread in the same stream
- He's starting a genuinely new discussion
Eagle needs to automatically detect which case it is and act accordingly.
Design
Detection Logic
Step 1: Find most recent active thread in stream
- Query Zulip API for recent messages in the current stream (last N hours)
- Exclude
(no topic)thread itself - Get the thread with the most recent message →
candidate_thread
Step 2: Relevance check
- Take first ~200 chars of
(no topic)message - Take thread name + last 2-3 messages from
candidate_threadas context - Ask LLM (cheap/fast call): "Is this message topically related to this thread? yes/no + confidence"
Step 3: Action
If related (confidence > 0.7):
- Quote Alex's message in
candidate_threadwith attribution:[Cross-posted from (no topic) — @Alex] - Reply in
(no topic):Переношу в тред «{thread_name}» ↗ - Continue discussion in
candidate_thread - ⚠️ Coordination: check if another Eagle instance is active in
candidate_thread(see below)
If not related (new topic):
- Rename
(no topic)thread to a descriptive topic name (2-5 words, inferred from message) - Reply normally in the renamed thread
Multi-instance Coordination
Eagle may have multiple instances running in different Zulip topics simultaneously. To avoid conflicts when routing to an existing thread:
Option A: DB mutex (preferred)
zulip_thread_lockstable:(stream, topic, locked_by_session, locked_at)- Before posting in another thread → INSERT lock (fail on conflict)
- Release lock after posting
- Stale lock (>5 min) → auto-expire
Option B: Message-based handoff
- Before cross-posting → send a "silent" message to
candidate_thread(deletable):[Eagle routing incoming from (no topic)] - If another instance replies with
[ACK]→ let it handle - If no reply in 30s → proceed
Recommendation: Option A — simpler, no timing issues.
Multi-instance Awareness
Each Eagle instance should know its own Zulip topic context. When spawned in a topic, it registers itself in zulip_thread_locks. Eagle reads this to avoid double-posting.
Implementation
Mechanism: pre_gateway_dispatch plugin
Hermes plugins support a pre_gateway_dispatch hook that fires before auth
and agent dispatch, can intercept, rewrite, or skip any incoming MessageEvent.
Plugin location: ~/.hermes/plugins/zulip-topic-routing/
In Zulip adapter (gateway/platforms/zulip.py):
event.source.thread_id= Zulip topic name (set at line ~610)event.source.chat_id="stream_name::topic_name"(no topic)messages havethread_id == "(no topic)"
Auth for Zulip API calls
From ~/.hermes/.env:
ZULIP_URL=https://zulip.qentra.top
ZULIP_BOT_EMAIL=eagle-bot@zulip.local
ZULIP_API_KEY=BT7zzT...XqAE
Topic rename API (confirmed working via curl in prior session):
PATCH /api/v1/messages/{message_id}?propagate_mode=change_all&topic={new_topic}
Create message in topic (effectively creates topic):
POST /api/v1/messages type=stream to=stream_name topic=new_topic content=...
Plugin Flow
pre_gateway_dispatchfires → checkevent.source.thread_id == "(no topic)"- Get stream name from
chat_id.split("::")[0] - Query Zulip API:
GET /api/v1/messages?narrow=[{"operator":"stream","operand":"<stream>"}]&num_before=0&num_after=20&anchor=newest - Filter out
(no topic)messages → get most recent topic name + last N messages - LLM relevance check (fast, cheap): is
event.textrelated to recent topic context? - If related → rewrite event, prepend
[routed from (no topic)], changeevent.source.thread_id - If not related → rename
(no topic)thread → descriptive 2-5 word topic, returnallow
Multi-instance Coordination
Dropped for v1 — single Eagle instance in practice. Can add mutex later if needed.
Implementation Steps
- Confirmed curl approach works for topic creation
- Identified
pre_gateway_dispatchas correct hook - Confirmed
thread_id == "(no topic)"detection pattern - Create
~/.hermes/plugins/zulip-topic-routing/plugin.yaml - Create
~/.hermes/plugins/zulip-topic-routing/__init__.py - Enable plugin in
~/.hermes/config.yaml - Test: send
(no topic)message, verify routing
Notes
- Topic rename API requires
propagate_mode=change_allto rename all messages in thread - Zulip uses
subjectfield internally,thread_idin Hermes maps to it - Free streams setting:
ZULIP_FREE_STREAMS=master,daily-brief,inbox,executor,personal,focus - Plugin lives in
~/.hermes/plugins/(user plugins, override bundled)