Files
obsidian-vault/work/plans/no-topic-routing.md
T

4.9 KiB

Plan: (no topic) Thread Routing

Date: 2026-05-14 Status: Done — tested and working (2026-05-14) 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:

  1. He missed the correct topic — it should go to the most recent active thread in the same stream
  2. 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_thread as 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_thread with 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_locks table: (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 have thread_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

  1. pre_gateway_dispatch fires → check event.source.thread_id == "(no topic)"
  2. Get stream name from chat_id.split("::")[0]
  3. Query Zulip API: GET /api/v1/messages?narrow=[{"operator":"stream","operand":"<stream>"}]&num_before=0&num_after=20&anchor=newest
  4. Filter out (no topic) messages → get most recent topic name + last N messages
  5. LLM relevance check (fast, cheap): is event.text related to recent topic context?
  6. If related → rewrite event, prepend [routed from (no topic)], change event.source.thread_id
  7. If not related → rename (no topic) thread → descriptive 2-5 word topic, return allow

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_dispatch as 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_all to rename all messages in thread
  • Zulip uses subject field internally, thread_id in 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)