125 lines
4.9 KiB
Markdown
125 lines
4.9 KiB
Markdown
# 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
|
|
|
|
- [x] Confirmed curl approach works for topic creation
|
|
- [x] Identified `pre_gateway_dispatch` as correct hook
|
|
- [x] Confirmed `thread_id == "(no topic)"` detection pattern
|
|
- [x] Create `~/.hermes/plugins/zulip-topic-routing/plugin.yaml`
|
|
- [x] Create `~/.hermes/plugins/zulip-topic-routing/__init__.py`
|
|
- [x] Enable plugin in `~/.hermes/config.yaml`
|
|
- [x] 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)
|