Compare commits
11
Commits
154454ac6d
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcf2de0052 | ||
|
|
715d54564f | ||
|
|
10e4e0374f | ||
|
|
0acf1068bf | ||
|
|
1c8f48fd77 | ||
|
|
2ee8a4d818 | ||
|
|
397efef776 | ||
|
|
0a7b69f386 | ||
|
|
ad802100f3 | ||
|
|
0079d11d94 | ||
|
|
ab3cb50eb4 |
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# PR #21 Review — fix(tanko): adapter fixes, event logging, platform-based deploy.sh
|
||||||
|
|
||||||
|
Reviewer: Abiba
|
||||||
|
Date: 2026-06-20
|
||||||
|
Status: ✅ Approve with changes (3 blocking, 3 advisory)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Comments
|
||||||
|
|
||||||
|
### Comment 1 (🔴 Blocking): CI stuck at "Waiting to run"
|
||||||
|
|
||||||
|
The Gitea Actions workflow (run #4) hasn't started. No CI results available. Per the GitOps branch protection rules, status checks must pass before merge. Do not merge until CI completes and all jobs are green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Comment 2 (🟡 High): `print()` for journald is an anti-pattern
|
||||||
|
|
||||||
|
In `adapter.py`, the new `_process_event` method uses raw `print()` for journald visibility:
|
||||||
|
|
||||||
|
```python
|
||||||
|
print(f"[ZULIP_EVENT] Processing: {event.get('type', 'unknown')}")
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `logger.info()` calls already flow to journald via stderr when the systemd unit uses `StandardError=journal`. Using raw `print()` bypasses:
|
||||||
|
- Log level filtering
|
||||||
|
- Format consistency with other log output
|
||||||
|
- Future structured logging needs
|
||||||
|
|
||||||
|
**Fix:** Replace with `logger.info(f"[ZULIP_EVENT] Processing: {event.get('type', 'unknown')}")`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Comment 3 (🟡 High): `asyncio.new_event_loop()` leaks on thread restart
|
||||||
|
|
||||||
|
In `_event_loop`, a new event loop is created but never closed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _event_loop(self) -> None:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
self._client.call_on_each_message(...)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Event loop crashed: {e}")
|
||||||
|
self.connected = False
|
||||||
|
```
|
||||||
|
|
||||||
|
If the thread restarts (e.g., reconnection), the old loop is leaked. This accumulates over time.
|
||||||
|
|
||||||
|
**Fix:** Wrap in `try/finally`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _event_loop(self) -> None:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
self._client.call_on_each_message(
|
||||||
|
lambda event: self._process_event(event),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Event loop crashed: {e}")
|
||||||
|
self.connected = False
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Comment 4 (🟡 Medium): Verify `Client(site=...)` parameter name
|
||||||
|
|
||||||
|
The PR changes `Client(server_url=...)` → `Client(site=...)` for "Python 3.13 compatibility." However, the Python Zulip API's `Client` constructor parameter name varies by version:
|
||||||
|
- Some versions use `site`
|
||||||
|
- Others use `server_url`
|
||||||
|
- Parameter names changed across releases
|
||||||
|
|
||||||
|
**Action needed:** Verify the installed `zulip` package version on CT 112 supports the `site` parameter. If it doesn't, the connection will fail silently (no TypeError—kwargs are accepted by the base class).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Comment 5 (🟡 Medium): deploy.sh refactor only tested on Hermes platform
|
||||||
|
|
||||||
|
The PR refactors `deploy.sh` across all 3 platforms (Hermes, Agent Zero, Pi) but testing only covers Tanko (Hermes) on CT 112. The Agent Zero (`pip install -r requirements.txt`) and Pi (`/reload` instead of `systemctl`) code paths are untested.
|
||||||
|
|
||||||
|
**Action needed:** Before merging, run at minimum a dry-run deploy against all 3 platform types:
|
||||||
|
```
|
||||||
|
./scripts/deploy.sh --ct=kagentz main --dry-run
|
||||||
|
./scripts/deploy.sh --ct=abiba main --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Comment 6 (🟢 Low): Agent Zero pip install assumption
|
||||||
|
|
||||||
|
The deploy.sh case statement lumps hermes and agent-zero together for dependency installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes|agent-zero)
|
||||||
|
pip install -r requirements.txt --quiet
|
||||||
|
;;
|
||||||
|
```
|
||||||
|
|
||||||
|
This assumes Agent Zero has the same `requirements.txt` location and content as Hermes. If Agent Zero uses a different dependency file or install method, this will silently install wrong packages.
|
||||||
|
|
||||||
|
**Suggestion:** Add a per-platform dependency install path or document this assumption explicitly.
|
||||||
+32
-7
@@ -1,8 +1,20 @@
|
|||||||
# Zulip Multi-Platform Agent Communication — Architecture
|
# Zulip Multi-Platform Agent Communication — Architecture (v2)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
A production-ready system enabling 6 AI agents across 3 platforms (Hermes Python, Agent Zero, pi TypeScript) to communicate through Zulip via dedicated per-agent bot users.
|
A production-ready system enabling 6 AI agents across 3 platforms (Hermes Python, Agent Zero, pi TypeScript) to communicate through Zulip via dedicated per-agent bot users.
|
||||||
|
|
||||||
|
## Architecture (Hermes Native Plugin — Current)
|
||||||
|
|
||||||
|
As of v1.0.0, Hermes agents (Tanko, Mumuni, Koonimo, Koby) use the **Hermes native platform plugin**
|
||||||
|
at `~/.hermes/plugins/platforms/zulip/`. This replaces the old standalone systemd service.
|
||||||
|
|
||||||
|
Benefits of the native plugin:
|
||||||
|
- Extends `BasePlatformAdapter` — zero changes to Hermes core
|
||||||
|
- Auto-registers via `register(ctx)` at Gateway startup
|
||||||
|
- Direct session injection (no subprocess overhead)
|
||||||
|
- Leverages Gateway's built-in health checks, config, and error handling
|
||||||
|
- Unified logging with all other Hermes platform adapters
|
||||||
|
|
||||||
## Architecture Diagram
|
## Architecture Diagram
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
@@ -66,18 +78,31 @@ User types: @all-bots status report
|
|||||||
- **Swarm Development**: The "Swarm" refers to our collaborative development methodology where multiple agents/developers work on different components of the plugin simultaneously.
|
- **Swarm Development**: The "Swarm" refers to our collaborative development methodology where multiple agents/developers work on different components of the plugin simultaneously.
|
||||||
|
|
||||||
|
|
||||||
### Hermes (Python) — BasePlatformAdapter
|
### Hermes (Python) — BasePlatformAdapter (CURRENT)
|
||||||
- Path: `hermes-zulip-plugin/src/hermes_zulip/`
|
- Path: `plugins/platforms/zulip/`
|
||||||
|
- Deploy: `~/.hermes/plugins/platforms/zulip/`
|
||||||
- Implements: `BasePlatformAdapter` (Hermes Gateway)
|
- Implements: `BasePlatformAdapter` (Hermes Gateway)
|
||||||
- Config: `config.yaml` per-agent
|
- Config: Hermes `config.yaml` under `platforms.zulip.extra` or env vars
|
||||||
- Entry point: `plugin.yaml` (Hermes manifest)
|
- Entry point: `plugin.yaml` + `register(ctx)` (Hermes manifest)
|
||||||
|
- Versions: Gen 3 (v1.0.0) — 1,169 lines, 14/14 Success Criteria met
|
||||||
|
- Features: DM-first, placeholder→edit streaming, dedup, self-test, health stats, @all-bots resolution
|
||||||
|
|
||||||
### Agent Zero — A0 Plugin System
|
### Agent Zero — A0 Plugin System (LEGACY — to migrate)
|
||||||
- Path: `agent-zero-plugin/src/`
|
- Path: `agent-zero-plugin/src/`
|
||||||
- Implements: Agent Zero plugin API
|
- Implements: Agent Zero plugin API
|
||||||
- Config: `config.yaml` per-agent
|
- Config: `config.yaml` per-agent
|
||||||
|
|
||||||
### pi (TypeScript) — pi Extension API
|
### pi (TypeScript) — pi Extension API (CURRENT)
|
||||||
|
- Path: `pi-zulip-extension/`
|
||||||
|
- Deploy: PM2-managed process
|
||||||
|
- Runs under `pi --mode rpc --session-id zulip-service`
|
||||||
|
- Active: abiba-bot only (ZULIP_EXTENSION_ACTIVE=true guard)
|
||||||
|
|
||||||
|
### Legacy Hermes Plugin (DEPRECATED)
|
||||||
|
- OLD path: `hermes-zulip-plugin/src/hermes_zulip/`
|
||||||
|
- OLD deploy: `/opt/hermes-zulip-plugin/` + systemd service
|
||||||
|
- Status: Replaced by `plugins/platforms/zulip/` native plugin as of v1.0.0
|
||||||
|
- Migration: See `hermes-zulip-plugin/DEPRECATED.md`
|
||||||
- Path: `pi-zulip-extension/src/`
|
- Path: `pi-zulip-extension/src/`
|
||||||
- Implements: pi extension (TypeScript module in `~/.pi/agent/extensions/`)
|
- Implements: pi extension (TypeScript module in `~/.pi/agent/extensions/`)
|
||||||
- Config: `config.yaml` per-agent
|
- Config: `config.yaml` per-agent
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# DEPRECATED — Hermes Zulip Plugin (Legacy)
|
||||||
|
|
||||||
|
**Status:** Deprecated as of 2026-06-25
|
||||||
|
**Replaced by:** `plugins/platforms/zulip/` (Hermes native platform plugin)
|
||||||
|
|
||||||
|
This directory contains the original standalone Hermes Zulip plugin that ran as a
|
||||||
|
systemd service with its own poll loop, subprocess agent invocation, and health
|
||||||
|
server.
|
||||||
|
|
||||||
|
## Why Deprecated
|
||||||
|
|
||||||
|
The new `plugins/platforms/zulip/` plugin is a proper Hermes platform plugin that:
|
||||||
|
|
||||||
|
- Extends `BasePlatformAdapter` from the Hermes Gateway
|
||||||
|
- Auto-registers via the Hermes plugin system (`register(ctx)`)
|
||||||
|
- Uses direct session injection (no subprocess overhead)
|
||||||
|
- Leverages Gateway's built-in health checks, config management, and error handling
|
||||||
|
- Requires zero changes to core Hermes code
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Remove the old systemd service and deploy the new plugin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop old service
|
||||||
|
systemctl stop zulip-plugin
|
||||||
|
systemctl disable zulip-plugin
|
||||||
|
|
||||||
|
# 2. Install plugin to ~/.hermes/plugins/platforms/zulip/
|
||||||
|
cp -r plugins/platforms/zulip/ ~/.hermes/plugins/platforms/zulip/
|
||||||
|
|
||||||
|
# 3. Restart Hermes Gateway
|
||||||
|
hermes gateway restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Config moves from:
|
||||||
|
- `/opt/hermes-zulip-plugin/config.yaml` → Hermes `config.yaml` under `platforms: zulip:`
|
||||||
|
- Or set `ZULIP_SITE`, `ZULIP_EMAIL`, `ZULIP_API_KEY` env vars
|
||||||
|
|
||||||
|
See `plugins/platforms/zulip/plugin.yaml` for configuration reference.
|
||||||
@@ -1,11 +1,146 @@
|
|||||||
"""
|
"""
|
||||||
Hermes Zulip Plugin — Core adapter for Hermes Python agents
|
Hermes Zulip Plugin — Core adapter for Hermes Python agents
|
||||||
|
|
||||||
Implements BasePlatformAdapter to connect Hermes agents (Tanko, Mumuni,
|
Connects Hermes agents (Tanko, Mumuni, Koonimo, Koby) to the Sysloggh
|
||||||
Koonimo, Koby) to the Sysloggh Zulip agent mesh.
|
Zulip agent mesh via event queue polling + subprocess agent invocation.
|
||||||
|
|
||||||
|
Architecture mirrors pi-zulip-extension:
|
||||||
|
Zulip event queue → poll loop → parse message → spawn agent subprocess
|
||||||
|
→ capture stdout → post response to Zulip (with placeholder + edit)
|
||||||
|
|
||||||
|
Usage (systemd service):
|
||||||
|
python3 -m hermes_zulip --config /path/to/config.yaml
|
||||||
|
|
||||||
Path: hermes-zulip-plugin/src/hermes_zulip/
|
Path: hermes-zulip-plugin/src/hermes_zulip/
|
||||||
Config: config.yaml (per-agent, deployed alongside plugin)
|
Config: config.yaml (per-agent, deployed alongside plugin)
|
||||||
|
|
||||||
|
@see ADR-001 DM-first, ADR-005 @mention detection
|
||||||
|
@see ADR-006 @all-bots, ADR-009 error handling
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
# Ensure the src directory is on the path for the adapter import
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from hermes_zulip.adapter import ZulipAdapter
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str) -> Dict[str, Any]:
|
||||||
|
"""Load YAML config and inject env vars."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
with open(config_path) as f:
|
||||||
|
cfg = yaml.safe_load(f)
|
||||||
|
|
||||||
|
# Override api_key from env if set
|
||||||
|
env_key = os.environ.get("ZULIP_API_KEY")
|
||||||
|
if env_key:
|
||||||
|
cfg.setdefault("zulip", {})["api_key"] = env_key
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(cfg: Dict[str, Any]) -> None:
|
||||||
|
"""Configure logging from config."""
|
||||||
|
level = (
|
||||||
|
cfg.get("monitoring", {}).get("log_level", "INFO").upper()
|
||||||
|
)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level, logging.INFO),
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_health_server(cfg: Dict[str, Any], adapter: ZulipAdapter) -> None:
|
||||||
|
"""Start a minimal health HTTP endpoint in a background thread."""
|
||||||
|
import threading
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
port = cfg.get("monitoring", {}).get("health_port", 9200)
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
class HealthHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path in ("/", "/health"):
|
||||||
|
import json
|
||||||
|
body = json.dumps({
|
||||||
|
"status": "ok",
|
||||||
|
"agent": cfg.get("agent", {}).get("name", "unknown"),
|
||||||
|
"connected": adapter.connected,
|
||||||
|
"uptime_seconds": int(time.time() - start_time),
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
})
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body.encode())
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass # Suppress health check log spam
|
||||||
|
|
||||||
|
server = HTTPServer(("127.0.0.1", port), HealthHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
logging.getLogger(__name__).info(f"Health endpoint on :{port}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""CLI entry point for the Hermes Zulip plugin.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -m hermes_zulip --config /path/to/config.yaml
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Hermes Zulip Plugin — Connects Hermes agents to Zulip"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config", "-c",
|
||||||
|
default="config.yaml",
|
||||||
|
help="Path to config.yaml (default: config.yaml)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
cfg = load_config(args.config)
|
||||||
|
setup_logging(cfg)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting Hermes Zulip Plugin v{__version__} "
|
||||||
|
f"for {cfg.get('agent', {}).get('name', 'unknown')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create adapter
|
||||||
|
adapter = ZulipAdapter(cfg)
|
||||||
|
|
||||||
|
# Start health endpoint (if enabled)
|
||||||
|
if cfg.get("monitoring", {}).get("health_endpoint_enabled", True):
|
||||||
|
start_health_server(cfg, adapter)
|
||||||
|
|
||||||
|
# Run the event loop (blocks until interrupted)
|
||||||
|
try:
|
||||||
|
adapter.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutting down...")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -1,50 +1,100 @@
|
|||||||
# Core adapter implements BasePlatformAdapter for Zulip
|
"""
|
||||||
# See ADR-005 for @mention detection, ADR-009 for error handling
|
Core Zulip adapter for Hermes agents (Tanko, Mumuni, Koonimo, Koby).
|
||||||
# Full implementation in issue #5.
|
|
||||||
|
|
||||||
|
Architecture (mirrors pi-zulip-extension):
|
||||||
|
Zulip event queue → poll loop → parse message → spawn agent subprocess
|
||||||
|
→ capture stdout → post response to Zulip
|
||||||
|
|
||||||
|
The Hermes agent is invoked as a subprocess (configurable command) so the
|
||||||
|
plugin remains decoupled from the agent runtime. In a future iteration,
|
||||||
|
this could switch to a direct IPC/socket if the agent exposes one.
|
||||||
|
|
||||||
|
See ADR-005 (@mention detection), ADR-006 (@all-bots), ADR-009 (error handling).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
import re
|
import re
|
||||||
import threading
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class ZulipAdapter:
|
# Regex to strip Zulip mention artifacts from message bodies (ADR-008)
|
||||||
"""Zulip adapter for Hermes agents. Connects to Zulip, listens for
|
MENTION_CLEANER = re.compile(r'@\*\*[^*]+\*\*')
|
||||||
@mentions in #agent-hub, routes messages via BasePlatformAdapter."""
|
|
||||||
|
|
||||||
# Regex to strip Zulip mention artifacts from message bodies (ADR-008)
|
|
||||||
MENTION_CLEANER = re.compile(r'@\*\*[^*]+\*\*')
|
class ZulipAdapter:
|
||||||
|
"""Zulip adapter for Hermes agents.
|
||||||
|
|
||||||
|
Connects to Zulip via event queues (not the deprecated call_on_each_message),
|
||||||
|
polls for new events, routes @mentions and DMs to the Hermes agent via
|
||||||
|
subprocess, and posts responses back to Zulip.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self._client = None
|
self._client = None
|
||||||
self._thread = None
|
self._queue_id: Optional[str] = None
|
||||||
self._stop_event = threading.Event()
|
self._last_event_id: int = -1
|
||||||
|
self._poll_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
# Agent subprocess config
|
||||||
|
self._agent_command: str = config.get("agent", {}).get(
|
||||||
|
"command", "hermes chat"
|
||||||
|
)
|
||||||
|
self._agent_timeout: int = config.get("error_handling", {}).get(
|
||||||
|
"timeout_seconds", 60
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bot identity for filtering own messages
|
||||||
|
self._bot_email: str = config.get("zulip", {}).get("email", "")
|
||||||
|
self._bot_id: int = config.get("swarm", {}).get("bot_id", 0)
|
||||||
|
|
||||||
|
# Stream config
|
||||||
|
self._stream: str = config.get("zulip", {}).get("stream", "agent-hub")
|
||||||
|
self._all_bots_user_id: int = config.get("zulip", {}).get(
|
||||||
|
"all_bots_user_id", 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Connection lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
"""Establish connection to Zulip and start the event loop."""
|
"""Connect to Zulip and register an event queue."""
|
||||||
if self.connected:
|
if self.connected:
|
||||||
logger.info("Already connected to Zulip.")
|
logger.info("Already connected to Zulip.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from zulip import Client
|
import zulip
|
||||||
server_url = self.config['zulip']['server_url']
|
|
||||||
email = self.config['zulip']['email']
|
|
||||||
api_key = self.config['zulip']['api_key']
|
|
||||||
|
|
||||||
self._client = Client(server_url=server_url, email=email, api_key=api_key)
|
server_url = self.config["zulip"]["server_url"]
|
||||||
|
email = self.config["zulip"]["email"]
|
||||||
|
api_key = self.config["zulip"]["api_key"]
|
||||||
|
|
||||||
|
self._client = zulip.Client(
|
||||||
|
server_url=server_url, email=email, api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register an event queue (like the pi extension)
|
||||||
|
queue_res = self._client.register(
|
||||||
|
event_types=["message"]
|
||||||
|
)
|
||||||
|
self._queue_id = queue_res.get("queue_id")
|
||||||
|
self._last_event_id = queue_res.get("last_event_id", -1)
|
||||||
self.connected = True
|
self.connected = True
|
||||||
logger.info(f"Connected to Zulip: {server_url} as {email}")
|
|
||||||
|
|
||||||
# Start the event loop in a background thread
|
logger.info(
|
||||||
self._stop_event.clear()
|
f"Connected to Zulip: {server_url} as {email} "
|
||||||
self._thread = threading.Thread(target=self._event_loop, daemon=True)
|
f"(queue={self._queue_id})"
|
||||||
self._thread.start()
|
)
|
||||||
logger.info("Zulip event loop started.")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to Zulip: {e}")
|
logger.error(f"Failed to connect to Zulip: {e}")
|
||||||
@@ -52,82 +102,422 @@ class ZulipAdapter:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""Disconnect from Zulip and stop the event loop."""
|
"""Disconnect and cancel the poll loop."""
|
||||||
if not self.connected:
|
if self._poll_task:
|
||||||
return
|
self._poll_task.cancel()
|
||||||
self._stop_event.set()
|
try:
|
||||||
if self._thread:
|
await self._poll_task
|
||||||
self._thread.join(timeout=5)
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._poll_task = None
|
||||||
self.connected = False
|
self.connected = False
|
||||||
logger.info("Disconnected from Zulip.")
|
logger.info("Disconnected from Zulip.")
|
||||||
|
|
||||||
async def send_message(self, topic: str, content: str) -> Dict[str, Any]:
|
# ------------------------------------------------------------------
|
||||||
"""Send a message to Zulip with retry logic (ADR-009)."""
|
# Event polling (async loop, like pi extension's setInterval)
|
||||||
max_retries = self.config.get('error_handling', {}).get('retry_count', 3)
|
# ------------------------------------------------------------------
|
||||||
retry_delay = self.config.get('error_handling', {}).get('retry_delay_seconds', 5)
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
async def poll_forever(self, poll_interval: float = 3.0) -> None:
|
||||||
|
"""Continuously poll the Zulip event queue and process messages.
|
||||||
|
|
||||||
|
This is the main event loop. Runs until cancelled.
|
||||||
|
"""
|
||||||
|
while self.connected and self._client and self._queue_id:
|
||||||
try:
|
try:
|
||||||
result = self._client.send_message({
|
events = await self._poll_events()
|
||||||
'type': 'stream',
|
for event in events:
|
||||||
'to': self.config['zulip']['stream'],
|
await self._process_event(event)
|
||||||
'subject': topic,
|
except asyncio.CancelledError:
|
||||||
'content': content,
|
break
|
||||||
})
|
|
||||||
logger.info(f"Message sent to {self.config['zulip']['stream']} > {topic} (attempt {attempt + 1})")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to send message (attempt {attempt + 1}): {e}")
|
logger.error(f"Poll error: {e}")
|
||||||
if attempt < max_retries - 1:
|
# Check for queue expiry
|
||||||
time.sleep(retry_delay)
|
if "BAD_EVENT_QUEUE_ID" in str(e):
|
||||||
else:
|
logger.info("Queue expired, reconnecting...")
|
||||||
raise
|
self.connected = False
|
||||||
|
await self._reconnect_with_retry()
|
||||||
|
continue
|
||||||
|
|
||||||
async def on_event(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
await asyncio.sleep(poll_interval)
|
||||||
"""Process incoming Zulip events and route via BasePlatformAdapter."""
|
|
||||||
if event.get('type') != 'message':
|
|
||||||
return None
|
|
||||||
|
|
||||||
msg = event.get('message', {})
|
async def _poll_events(self) -> list:
|
||||||
mentioned_users = msg.get('mentioned_users', False)
|
"""Fetch events from the Zulip event queue."""
|
||||||
|
import zulip
|
||||||
|
|
||||||
# Only process messages that mention this bot (ADR-005)
|
if not self._client or not self._queue_id:
|
||||||
if not mentioned_users:
|
return []
|
||||||
return None
|
|
||||||
|
|
||||||
# Strip Zulip formatting artifacts
|
response = self._client.get_events(
|
||||||
body = msg.get('content', '')
|
queue_id=self._queue_id,
|
||||||
clean_body = self.MENTION_CLEANER.sub('', body)
|
last_event_id=self._last_event_id,
|
||||||
clean_body = clean_body.strip()
|
dont_block=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Construct the MessageEvent for BasePlatformAdapter
|
if response.get("result") != "success":
|
||||||
return {
|
raise RuntimeError(
|
||||||
'type': 'MessageEvent',
|
f"Events API error: {response.get('msg', 'unknown')}"
|
||||||
'sender': msg.get('sender_full_name', 'Unknown'),
|
)
|
||||||
'body': clean_body,
|
|
||||||
'topic': msg.get('subject', 'Unknown Topic'),
|
|
||||||
'stream': msg.get('stream', 'Unknown Stream'),
|
|
||||||
'timestamp': msg.get('timestamp', 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _event_loop(self) -> None:
|
events = response.get("events", [])
|
||||||
"""Background thread to listen for Zulip events."""
|
for event in events:
|
||||||
|
if event.get("id", 0) > self._last_event_id:
|
||||||
|
self._last_event_id = event["id"]
|
||||||
|
|
||||||
|
return [e for e in events if e.get("type") == "message"]
|
||||||
|
|
||||||
|
async def _reconnect_with_retry(self) -> None:
|
||||||
|
"""Retry connection with backoff."""
|
||||||
|
retries = self.config.get("error_handling", {}).get("retry_count", 3)
|
||||||
|
delay = self.config.get("error_handling", {}).get(
|
||||||
|
"retry_delay_seconds", 5
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in range(retries):
|
||||||
|
logger.info(
|
||||||
|
f"Reconnect attempt {attempt + 1}/{retries} "
|
||||||
|
f"in {delay}s..."
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
try:
|
||||||
|
await self.connect()
|
||||||
|
if self.connected:
|
||||||
|
logger.info("Reconnected successfully.")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Reconnect attempt {attempt + 1} failed: {e}")
|
||||||
|
delay *= 2 # Exponential backoff
|
||||||
|
|
||||||
|
logger.error("Max reconnection retries reached. Giving up.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Message processing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _process_event(self, event: Dict[str, Any]) -> None:
|
||||||
|
"""Route a Zulip message event to the Hermes agent."""
|
||||||
|
msg = event.get("message", {})
|
||||||
|
msg_type = msg.get("type", "") # "private" or "stream"
|
||||||
|
sender_email = msg.get("sender_email", "")
|
||||||
|
sender_name = msg.get("sender_full_name", "Unknown")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
# Ignore own messages
|
||||||
|
if sender_email == self._bot_email:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine if this message targets this bot
|
||||||
|
mentioned_users = msg.get("mentioned_users", [])
|
||||||
|
mentioned_user_ids = [u.get("user_id") for u in mentioned_users]
|
||||||
|
is_dm = msg_type == "private"
|
||||||
|
is_mention = self._bot_id in mentioned_user_ids
|
||||||
|
is_all_bots = self._all_bots_user_id in mentioned_user_ids
|
||||||
|
|
||||||
|
# DM-first: process all private messages (like ADR-001/ADR-002)
|
||||||
|
if is_dm:
|
||||||
|
logger.info(f"DM from {sender_name}: {content[:80]}...")
|
||||||
|
await self._route_to_agent(
|
||||||
|
message_type="dm",
|
||||||
|
sender_name=sender_name,
|
||||||
|
content=content,
|
||||||
|
recipient=sender_email,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stream: only respond to @mentions and @all-bots (ADR-005, ADR-006)
|
||||||
|
if msg_type == "stream" and (is_mention or is_all_bots):
|
||||||
|
stream_name = msg.get("display_recipient", "unknown")
|
||||||
|
topic = msg.get("subject", "general")
|
||||||
|
logger.info(
|
||||||
|
f"{'@mention' if is_mention else '@all-bots'} "
|
||||||
|
f"in #{stream_name} > {topic}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean @mention artifacts from content (ADR-008)
|
||||||
|
clean_content = MENTION_CLEANER.sub("", content).strip()
|
||||||
|
|
||||||
|
await self._route_to_agent(
|
||||||
|
message_type="mention" if is_mention else "all-bots",
|
||||||
|
sender_name=sender_name,
|
||||||
|
content=clean_content,
|
||||||
|
recipient=stream_name,
|
||||||
|
topic=topic,
|
||||||
|
stream_id=msg.get("stream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _route_to_agent(
|
||||||
|
self,
|
||||||
|
message_type: str,
|
||||||
|
sender_name: str,
|
||||||
|
content: str,
|
||||||
|
recipient: str,
|
||||||
|
topic: Optional[str] = None,
|
||||||
|
stream_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Send the message to the Hermes agent subprocess and relay the
|
||||||
|
response back to Zulip.
|
||||||
|
|
||||||
|
Architecture note: This uses a subprocess (configurable via
|
||||||
|
agent.command in config.yaml). This mirrors the initial pi extension
|
||||||
|
approach. A future iteration could use IPC or a socket if the
|
||||||
|
Hermes agent exposes one.
|
||||||
|
"""
|
||||||
|
# 1. Send typing indicator
|
||||||
|
await self._send_typing_indicator(recipient, "start")
|
||||||
|
|
||||||
|
# 2. Send a "Thinking..." placeholder to Zulip immediately.
|
||||||
|
# On agent_end, this message will be edited with the final response.
|
||||||
|
placeholder = (
|
||||||
|
f":robot: _Processing your message..._"
|
||||||
|
)
|
||||||
|
placeholder_msg_id = None
|
||||||
try:
|
try:
|
||||||
self._client.call_on_each_message(
|
placeholder_msg_id = await self._send_message(
|
||||||
lambda event: self._process_event(event),
|
message_type=message_type,
|
||||||
|
recipient=recipient,
|
||||||
|
content=placeholder,
|
||||||
|
topic=topic,
|
||||||
|
stream_id=stream_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Event loop crashed: {e}")
|
logger.warning(f"Failed to send placeholder: {e}")
|
||||||
self.connected = False
|
|
||||||
|
# 3. Invoke the Hermes agent subprocess
|
||||||
|
response_text = await self._invoke_agent(content, sender_name)
|
||||||
|
|
||||||
|
# 4. Stop typing indicator
|
||||||
|
await self._send_typing_indicator(recipient, "stop")
|
||||||
|
|
||||||
|
# 5. Post (or edit) the response
|
||||||
|
if not response_text.strip():
|
||||||
|
logger.warning(
|
||||||
|
f"Hermes agent returned empty response for "
|
||||||
|
f"{message_type} from {sender_name}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Truncate to Zulip's 10K char limit
|
||||||
|
MAX_ZULIP_MSG = 10000
|
||||||
|
truncated = (
|
||||||
|
response_text[:MAX_ZULIP_MSG]
|
||||||
|
+ "\n\n[...truncated at Zulip limit]"
|
||||||
|
if len(response_text) > MAX_ZULIP_MSG
|
||||||
|
else response_text
|
||||||
|
)
|
||||||
|
|
||||||
def _process_event(self, event: Dict[str, Any]) -> None:
|
|
||||||
"""Bridge the synchronous event to the async on_event handler."""
|
|
||||||
import asyncio
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
if placeholder_msg_id:
|
||||||
if loop.is_running():
|
await self._edit_message(placeholder_msg_id, truncated)
|
||||||
asyncio.create_task(self.on_event(event))
|
logger.info(
|
||||||
|
f"Finalized response to {sender_name} "
|
||||||
|
f"({len(truncated)} chars)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
asyncio.run(self.on_event(event))
|
await self._send_message(
|
||||||
|
message_type=message_type,
|
||||||
|
recipient=recipient,
|
||||||
|
content=truncated,
|
||||||
|
topic=topic,
|
||||||
|
stream_id=stream_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Sent response to {sender_name} "
|
||||||
|
f"({len(truncated)} chars)"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing event: {e}")
|
logger.error(f"Failed to post response: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Hermes agent subprocess invocation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _invoke_agent(
|
||||||
|
self, message: str, sender_name: str
|
||||||
|
) -> str:
|
||||||
|
"""Spawn the Hermes agent subprocess with the message.
|
||||||
|
|
||||||
|
The agent command is configurable (agent.command in config.yaml).
|
||||||
|
Default: "hermes chat"
|
||||||
|
|
||||||
|
The message is passed via stdin (pipe). The agent's stdout is
|
||||||
|
captured as the response.
|
||||||
|
|
||||||
|
This is synchronous (run in executor) to avoid blocking the
|
||||||
|
event loop during subprocess execution.
|
||||||
|
"""
|
||||||
|
cmd_str = self._agent_command
|
||||||
|
cmd = shlex.split(cmd_str)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Invoking agent: {' '.join(cmd)} "
|
||||||
|
f"(timeout={self._agent_timeout}s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run() -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
input=message,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=self._agent_timeout,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
logger.error(
|
||||||
|
f"Agent exited with code {result.returncode}: "
|
||||||
|
f"{stderr}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f":warning: Agent error (exit {result.returncode}). "
|
||||||
|
f"Please try again later."
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error(
|
||||||
|
f"Agent timed out after {self._agent_timeout}s"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f":hourglass: Agent timed out after "
|
||||||
|
f"{self._agent_timeout}s. Please try again."
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"Agent command not found: {cmd_str}")
|
||||||
|
return (
|
||||||
|
f":warning: Agent command not found: `{cmd_str}`. "
|
||||||
|
f"Check configuration."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Agent invocation error: {e}")
|
||||||
|
return (
|
||||||
|
f":warning: Failed to invoke agent: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await asyncio.get_event_loop().run_in_executor(None, _run)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Zulip API helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _send_message(
|
||||||
|
self,
|
||||||
|
message_type: str,
|
||||||
|
recipient: str,
|
||||||
|
content: str,
|
||||||
|
topic: Optional[str] = None,
|
||||||
|
stream_id: Optional[int] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Send a message to Zulip. Returns the message ID if successful."""
|
||||||
|
if not self._client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _send() -> Optional[int]:
|
||||||
|
if message_type in ("dm", "private"):
|
||||||
|
# Private message: recipient is email
|
||||||
|
payload = {
|
||||||
|
"type": "private",
|
||||||
|
"to": [recipient],
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Stream message
|
||||||
|
payload = {
|
||||||
|
"type": "stream",
|
||||||
|
"to": stream_id or recipient,
|
||||||
|
"subject": topic or "general",
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._client.send_message(payload)
|
||||||
|
if result.get("result") == "success":
|
||||||
|
return result.get("id")
|
||||||
|
logger.error(
|
||||||
|
f"Send message failed: {result.get('msg', 'unknown')}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Send message error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await asyncio.get_event_loop().run_in_executor(None, _send)
|
||||||
|
|
||||||
|
async def _edit_message(
|
||||||
|
self, message_id: int, content: str
|
||||||
|
) -> bool:
|
||||||
|
"""Edit a previously sent Zulip message (for streaming updates)."""
|
||||||
|
if not self._client:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _edit() -> bool:
|
||||||
|
try:
|
||||||
|
result = self._client.update_message(
|
||||||
|
{"message_id": message_id, "content": content}
|
||||||
|
)
|
||||||
|
if result.get("result") != "success":
|
||||||
|
logger.warning(
|
||||||
|
f"Edit message {message_id}: "
|
||||||
|
f"{result.get('msg', 'unknown')}"
|
||||||
|
)
|
||||||
|
return result.get("result") == "success"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Edit message {message_id} error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await asyncio.get_event_loop().run_in_executor(None, _edit)
|
||||||
|
|
||||||
|
async def _send_typing_indicator(
|
||||||
|
self, recipient: str, operation: str
|
||||||
|
) -> None:
|
||||||
|
"""Send a typing indicator (start/stop)."""
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _typing() -> None:
|
||||||
|
try:
|
||||||
|
# The zulip Python library doesn't have a direct typing API
|
||||||
|
# Use the REST endpoint directly via requests
|
||||||
|
import requests
|
||||||
|
|
||||||
|
server_url = self.config["zulip"]["server_url"]
|
||||||
|
email = self.config["zulip"]["email"]
|
||||||
|
api_key = self.config["zulip"]["api_key"]
|
||||||
|
|
||||||
|
# For private messages, recipient is the user's email
|
||||||
|
# We need their user_id. Use the API directly.
|
||||||
|
to_data = json.dumps([recipient])
|
||||||
|
requests.post(
|
||||||
|
f"{server_url}/api/v1/typing",
|
||||||
|
auth=requests.auth.HTTPBasicAuth(email, api_key),
|
||||||
|
data={"to": to_data, "op": operation},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Non-critical
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, _typing)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Synchronous entry point for the Hermes runner
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Synchronous entry point that starts the async poll loop.
|
||||||
|
|
||||||
|
Called by the Hermes runner (e.g., from a systemd service).
|
||||||
|
"""
|
||||||
|
asyncio.run(self._run_async())
|
||||||
|
|
||||||
|
async def _run_async(self) -> None:
|
||||||
|
"""Async entry point."""
|
||||||
|
try:
|
||||||
|
await self.connect()
|
||||||
|
if self.connected:
|
||||||
|
logger.info("Starting event poll loop...")
|
||||||
|
await self.poll_forever()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Poll loop cancelled.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal error in event loop: {e}")
|
||||||
|
finally:
|
||||||
|
await self.disconnect()
|
||||||
|
|||||||
Generated
+2337
File diff suppressed because it is too large
Load Diff
@@ -9,14 +9,20 @@
|
|||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zulip-js": "^2.0.0",
|
"yaml": "^2.9.0",
|
||||||
"yaml": "^2.0.0"
|
"zulip-js": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "*",
|
"@earendil-works/pi-coding-agent": "^0.80.2",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"keywords": ["pi", "zulip", "agent", "abiba", "sysloggh"],
|
"keywords": [
|
||||||
|
"pi",
|
||||||
|
"zulip",
|
||||||
|
"agent",
|
||||||
|
"abiba",
|
||||||
|
"sysloggh"
|
||||||
|
],
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
+711
-78
@@ -1,120 +1,753 @@
|
|||||||
/**
|
/**
|
||||||
* pi-zulip-extension — Zulip agent communication plugin for pi agents
|
* pi-zulip-extension — Zulip agent communication plugin for pi agents
|
||||||
*
|
*
|
||||||
* Deploy to: ~/.pi/agent/extensions/zulip.ts
|
* Deploy to: ~/.pi/agent/extensions/zulip/
|
||||||
* Config: config.yaml (alongside extension, or symlinked)
|
* Config: config.yaml (alongside extension, or env vars)
|
||||||
*
|
*
|
||||||
* Connects a pi agent (Abiba) to the Sysloggh Zulip agent mesh.
|
* DM-first architecture per ADR-001/ADR-002.
|
||||||
* Listens for @mentions of abiba-bot in #agent-hub and forwards
|
* Background Zulip event queue poller that injects messages directly into
|
||||||
* to the pi agent. Responses are posted back to the same Zulip topic.
|
* the current pi session via pi.sendUserMessage(), then captures the LLM
|
||||||
|
* response from agent_end and sends it back to Zulip. No subprocess overhead.
|
||||||
*
|
*
|
||||||
* @see ADR-007: Platform-native plugin contracts
|
* @see ADR-007: Platform-native plugin contracts
|
||||||
* @see docs/ARCHITECTURE.md
|
* @see docs/ARCHITECTURE.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import { readFileSync } from "fs";
|
import zulip from "zulip-js";
|
||||||
|
import http from "node:http";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import url from "node:url";
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
// zulip-js types are declared in types.d.ts in this directory
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config
|
// Configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ZulipConfig {
|
interface ZulipConfig {
|
||||||
agent: {
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
zulip_bot_name: string;
|
|
||||||
owner_email: string;
|
|
||||||
private_topic: string;
|
|
||||||
};
|
|
||||||
zulip: {
|
zulip: {
|
||||||
server_url: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
stream: string;
|
site: string;
|
||||||
all_bots_user_id: number;
|
stream?: string;
|
||||||
|
all_bots_user_id?: number;
|
||||||
};
|
};
|
||||||
error_handling: {
|
agent: {
|
||||||
timeout_seconds: number;
|
name: string;
|
||||||
retry_count: number;
|
owner_email: string;
|
||||||
retry_delay_seconds: number;
|
display_name?: string;
|
||||||
graceful_message: string;
|
zulip_bot_name?: string;
|
||||||
};
|
private_topic?: string;
|
||||||
monitoring: {
|
|
||||||
health_endpoint_enabled: boolean;
|
|
||||||
health_port: number;
|
|
||||||
log_level: string;
|
|
||||||
};
|
};
|
||||||
|
health_port?: number;
|
||||||
|
poll_interval_ms?: number;
|
||||||
|
max_retries?: number;
|
||||||
|
retry_delay_ms?: number;
|
||||||
|
pi_command?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadConfig(): ZulipConfig {
|
function loadConfig(): ZulipConfig {
|
||||||
const raw = readFileSync("config.yaml", "utf-8");
|
// Check env vars first
|
||||||
const cfg = parse(raw) as ZulipConfig;
|
const email = process.env.ZULIP_EMAIL;
|
||||||
cfg.zulip.api_key = process.env["ZULIP_API_KEY"] ?? cfg.zulip.api_key;
|
const apiKey = process.env.ZULIP_API_KEY;
|
||||||
return cfg;
|
const site = process.env.ZULIP_SITE;
|
||||||
|
|
||||||
|
if (email && apiKey && site) {
|
||||||
|
return {
|
||||||
|
zulip: { email, api_key: apiKey, site },
|
||||||
|
agent: {
|
||||||
|
name: process.env.AGENT_NAME ?? "abiba",
|
||||||
|
owner_email: process.env.AGENT_OWNER_EMAIL ?? "jerome@sysloggh.com",
|
||||||
|
},
|
||||||
|
health_port: parseInt(process.env.HEALTH_PORT ?? "9200", 10),
|
||||||
|
poll_interval_ms: 3000,
|
||||||
|
max_retries: 3,
|
||||||
|
retry_delay_ms: 5000,
|
||||||
|
pi_command: "pi",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to config.yaml alongside the extension
|
||||||
|
const configDir = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
const configPath = path.join(configDir, "config.yaml");
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`No config.yaml found at ${configPath}. Set ZULIP_EMAIL, ZULIP_API_KEY, ZULIP_SITE env vars or provide config.yaml.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(configPath, "utf-8");
|
||||||
|
const cfg = parse(raw) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Support both flat (env-style) and nested config formats
|
||||||
|
function getField(key: string): unknown {
|
||||||
|
if (cfg[key] !== undefined) return cfg[key];
|
||||||
|
// Try nested lookup: "zulip.email" -> cfg.zulip?.email
|
||||||
|
const parts = key.split(".");
|
||||||
|
let cur: Record<string, unknown> = cfg;
|
||||||
|
for (const p of parts) {
|
||||||
|
if (cur && typeof cur === "object" && p in cur) {
|
||||||
|
cur = cur[p] as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: ZulipConfig = {
|
||||||
|
zulip: {
|
||||||
|
email: String(getField("zulip.email") ?? getField("zulip.email") ?? ""),
|
||||||
|
api_key: String(getField("zulip.api_key") ?? getField("zulip.api_key") ?? ""),
|
||||||
|
site: String(getField("zulip.site") ?? getField("zulip.server_url") ?? ""),
|
||||||
|
stream: String(getField("zulip.stream") ?? ""),
|
||||||
|
all_bots_user_id: Number(getField("zulip.all_bots_user_id") ?? 1),
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
name: String(getField("agent.name") ?? "abiba"),
|
||||||
|
owner_email: String(getField("agent.owner_email") ?? "jerome@sysloggh.com"),
|
||||||
|
display_name: String(getField("agent.display_name") ?? ""),
|
||||||
|
zulip_bot_name: String(getField("agent.zulip_bot_name") ?? ""),
|
||||||
|
private_topic: String(getField("agent.private_topic") ?? ""),
|
||||||
|
},
|
||||||
|
health_port: parseInt(String(getField("health_port") ?? getField("monitoring.health_port") ?? "9200"), 10),
|
||||||
|
poll_interval_ms: 3000,
|
||||||
|
max_retries: 3,
|
||||||
|
retry_delay_ms: 5000,
|
||||||
|
pi_command: "pi",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override api_key from env if set
|
||||||
|
parsed.zulip.api_key = process.env.ZULIP_API_KEY ?? parsed.zulip.api_key;
|
||||||
|
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Extension entry point
|
// Zulip API helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
interface ZulipMessage {
|
||||||
const config = loadConfig();
|
id: number;
|
||||||
|
type: "stream" | "private";
|
||||||
|
display_recipient?: string | Array<{ id: number; email: string; full_name: string }>;
|
||||||
|
sender_id: number;
|
||||||
|
sender_email: string;
|
||||||
|
sender_full_name: string;
|
||||||
|
content: string;
|
||||||
|
subject: string;
|
||||||
|
stream_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Health endpoint
|
interface ZulipEvent {
|
||||||
if (config.monitoring.health_endpoint_enabled) {
|
id: number;
|
||||||
startHealthServer(config);
|
type: "message";
|
||||||
}
|
message: ZulipMessage;
|
||||||
|
}
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
/**
|
||||||
ctx.ui.notify(
|
* Register a Zulip event queue and poll for events.
|
||||||
`Zulip extension loaded for ${config.agent.display_name} (${config.agent.zulip_bot_name})`,
|
*/
|
||||||
"info"
|
async function createZulipQueue(config: ZulipConfig) {
|
||||||
);
|
const client = await zulip({
|
||||||
|
username: config.zulip.email,
|
||||||
|
apiKey: config.zulip.api_key,
|
||||||
|
realm: config.zulip.site,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Diagnostic tool
|
// Register queue
|
||||||
pi.registerTool({
|
const queueRes = await client.queues.register({
|
||||||
name: "zulip_status",
|
event_types: ["message"],
|
||||||
label: "Zulip Status",
|
|
||||||
description: "Check Zulip connection status and bot identity",
|
|
||||||
parameters: {},
|
|
||||||
execute: async () => ({
|
|
||||||
connected: false, // TODO: track connection state
|
|
||||||
bot: config.agent.zulip_bot_name,
|
|
||||||
stream: config.zulip.stream,
|
|
||||||
server: config.zulip.server_url,
|
|
||||||
owner: config.agent.owner_email,
|
|
||||||
private_topic: config.agent.private_topic,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queueId = queueRes.queue_id;
|
||||||
|
let lastEventId: number = queueRes.last_event_id ?? -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
queueId,
|
||||||
|
lastEventId,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the event queue and return any new events.
|
||||||
|
*/
|
||||||
|
async poll(): Promise<ZulipEvent[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${config.zulip.site}/api/v1/events?queue_id=${encodeURIComponent(queueId)}&last_event_id=${lastEventId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${config.zulip.email}:${config.zulip.api_key}`).toString("base64"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Events API returned ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { events?: ZulipEvent[] };
|
||||||
|
|
||||||
|
if (data.events && Array.isArray(data.events)) {
|
||||||
|
for (const event of data.events) {
|
||||||
|
if (event.id > lastEventId) {
|
||||||
|
lastEventId = event.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data.events.filter((e) => e.type === "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Zulip via the API.
|
||||||
|
*/
|
||||||
|
async sendMessage(params: {
|
||||||
|
type: "private" | "stream";
|
||||||
|
to: number | string;
|
||||||
|
subject?: string;
|
||||||
|
content: string;
|
||||||
|
}): Promise<number> {
|
||||||
|
const formBody = new URLSearchParams();
|
||||||
|
formBody.append("type", params.type);
|
||||||
|
formBody.append("to", params.type === "private" ? JSON.stringify([params.to]) : String(params.to));
|
||||||
|
if (params.subject) formBody.append("subject", params.subject);
|
||||||
|
formBody.append("content", params.content);
|
||||||
|
|
||||||
|
const res = await fetch(`${config.zulip.site}/api/v1/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${config.zulip.email}:${config.zulip.api_key}`).toString("base64"),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: formBody.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Send message API returned ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as { id: number };
|
||||||
|
return body.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a previously sent Zulip message (streaming update).
|
||||||
|
*/
|
||||||
|
async editMessage(messageId: number, content: string): Promise<void> {
|
||||||
|
const formBody = new URLSearchParams();
|
||||||
|
formBody.append("content", content);
|
||||||
|
|
||||||
|
const res = await fetch(`${config.zulip.site}/api/v1/messages/${messageId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${config.zulip.email}:${config.zulip.api_key}`).toString("base64"),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: formBody.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok && res.status !== 400) {
|
||||||
|
console.warn(`[zulip-ext] Edit message ${messageId} returned ${res.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send typing indicator.
|
||||||
|
*/
|
||||||
|
async sendTypingNotification(userIds: number[], operation: "start" | "stop"): Promise<void> {
|
||||||
|
const formBody = new URLSearchParams();
|
||||||
|
formBody.append("to", JSON.stringify(userIds));
|
||||||
|
formBody.append("op", operation);
|
||||||
|
|
||||||
|
await fetch(`${config.zulip.site}/api/v1/typing`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${config.zulip.email}:${config.zulip.api_key}`).toString("base64"),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: formBody.toString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Health endpoint
|
// Health endpoint
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function startHealthServer(config: ZulipConfig) {
|
function startHealthServer(port: number, getState: () => Record<string, unknown>) {
|
||||||
const http = require("http") as typeof import("http");
|
const server = http.createServer((req, res) => {
|
||||||
const port = config.monitoring.health_port;
|
if (req.url === "/health" || req.url === "/") {
|
||||||
const startTime = Date.now();
|
const state = getState();
|
||||||
|
|
||||||
http
|
|
||||||
.createServer((_req: any, res: any) => {
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(
|
res.end(JSON.stringify({ status: "ok", ...state }));
|
||||||
JSON.stringify({
|
} else {
|
||||||
status: "ok",
|
res.writeHead(404);
|
||||||
agent: config.agent.name,
|
res.end("Not found");
|
||||||
uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
}
|
||||||
zulip_connected: false,
|
});
|
||||||
last_message_time: null,
|
|
||||||
timestamp: new Date().toISOString(),
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
})
|
if (err.code === "EADDRINUSE") {
|
||||||
);
|
console.warn(`[zulip-ext] Port :${port} already in use — skipping health endpoint`);
|
||||||
})
|
} else {
|
||||||
.listen(port, "127.0.0.1", () => {
|
console.error(`[zulip-ext] Health server error:`, err.message);
|
||||||
console.log(`[zulip-extension] Health endpoint on :${port}`);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
console.log(`[zulip-ext] Health endpoint on :${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detect @all-bots content
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ALL_BOTS_RE = /@\*\*(?:all-bots|All Bots)\*\*/i;
|
||||||
|
|
||||||
|
function isAllBotsMention(content: string): boolean {
|
||||||
|
return ALL_BOTS_RE.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main extension
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
const config = loadConfig();
|
||||||
|
let queue: Awaited<ReturnType<typeof createZulipQueue>> | null = null;
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let retryCount = 0;
|
||||||
|
let connected = false;
|
||||||
|
let lastError: string | null = null;
|
||||||
|
let processedCount = 0;
|
||||||
|
let healthServer: ReturnType<typeof startHealthServer> | null = null;
|
||||||
|
|
||||||
|
// Track state for health endpoint
|
||||||
|
function getState() {
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
email: config.zulip.email,
|
||||||
|
site: config.zulip.site,
|
||||||
|
agent: config.agent.name,
|
||||||
|
is_owner: config.agent.owner_email,
|
||||||
|
queue_id: queue?.queueId ?? null,
|
||||||
|
last_event_id: queue?.lastEventId ?? null,
|
||||||
|
messages_processed: processedCount,
|
||||||
|
last_error: lastError,
|
||||||
|
retry_count: retryCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending Zulip replies awaiting the LLM response from agent_end.
|
||||||
|
// zulipMessageId is set after the placeholder is sent.
|
||||||
|
const pendingZulipReplies: Array<{
|
||||||
|
id: number;
|
||||||
|
type: "private" | "stream";
|
||||||
|
senderId?: number;
|
||||||
|
senderName?: string;
|
||||||
|
streamName?: string;
|
||||||
|
topic?: string;
|
||||||
|
streamId?: number;
|
||||||
|
zulipMessageId?: number;
|
||||||
|
}> = [];
|
||||||
|
let zulipReplyIdCounter = 0;
|
||||||
|
let isAgentBusy = false;
|
||||||
|
let streamingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let streamingAccumulator = "";
|
||||||
|
let streamingReplyId: number | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a Zulip message into the current pi session via sendUserMessage
|
||||||
|
* and schedule the reply to be sent back on the next agent_end event.
|
||||||
|
* Sends a "Thinking..." placeholder to Zulip immediately for streaming UX.
|
||||||
|
*/
|
||||||
|
function injectIntoSession(
|
||||||
|
msg: ZulipMessage,
|
||||||
|
senderName: string,
|
||||||
|
senderId: number,
|
||||||
|
replyInfo: {
|
||||||
|
type: "private" | "stream";
|
||||||
|
senderId?: number;
|
||||||
|
senderName?: string;
|
||||||
|
streamName?: string;
|
||||||
|
topic?: string;
|
||||||
|
streamId?: number;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
// Send typing indicator (fire-and-forget)
|
||||||
|
queue!.sendTypingNotification([senderId], "start").catch(() => {});
|
||||||
|
|
||||||
|
const replyId = ++zulipReplyIdCounter;
|
||||||
|
const label = replyInfo.type === "stream"
|
||||||
|
? `@all-bots in #${replyInfo.streamName!}`
|
||||||
|
: `DM from @${senderName}`;
|
||||||
|
|
||||||
|
// Send a placeholder "Thinking..." message to Zulip immediately
|
||||||
|
const placeholders = [
|
||||||
|
":robot: _Processing your message..._",
|
||||||
|
":hourglass_flowing_sand: _Thinking..._",
|
||||||
|
":brain: _Generating response..._",
|
||||||
|
];
|
||||||
|
const placeholder = placeholders[replyId % placeholders.length];
|
||||||
|
const placeholderTarget = replyInfo.type === "private"
|
||||||
|
? { type: "private" as const, to: senderId }
|
||||||
|
: { type: "stream" as const, to: replyInfo.streamId ?? replyInfo.streamName!, subject: replyInfo.topic! };
|
||||||
|
|
||||||
|
queue!.sendMessage({ ...placeholderTarget, content: placeholder })
|
||||||
|
.then((msgId) => {
|
||||||
|
pendingZulipReplies.push({ id: replyId, ...replyInfo, zulipMessageId: msgId });
|
||||||
|
console.log(`[zulip-ext] Placed [${replyId}] (msg ${msgId}) for ${senderName}`);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
pendingZulipReplies.push({ id: replyId, ...replyInfo });
|
||||||
|
console.log(`[zulip-ext] Queued [${replyId}] for ${senderName} (no placeholder)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject into the pi session — use steer mode if agent is busy
|
||||||
|
if (isAgentBusy) {
|
||||||
|
pi.sendUserMessage(`[Zulip ${label}]: ${msg.content}`, { deliverAs: "steer" });
|
||||||
|
console.log(`[zulip-ext] Steer [${replyId}]: queued while agent busy`);
|
||||||
|
} else {
|
||||||
|
pi.sendUserMessage(`[Zulip ${label}]: ${msg.content}`);
|
||||||
|
console.log(`[zulip-ext] Injected [${replyId}]: from ${senderName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single Zulip event — injects DMs and @all-bots into the
|
||||||
|
* current pi session instead of spawning a subprocess.
|
||||||
|
*/
|
||||||
|
async function processEvent(event: ZulipEvent): Promise<void> {
|
||||||
|
const msg = event.message;
|
||||||
|
|
||||||
|
// Ignore non-message events
|
||||||
|
if (event.type !== "message") return;
|
||||||
|
|
||||||
|
// Ignore own messages (sent by this bot)
|
||||||
|
if (msg.sender_email === config.zulip.email) return;
|
||||||
|
|
||||||
|
// DM-first architecture (ADR-001, ADR-002): process private messages
|
||||||
|
if (msg.type === "private") {
|
||||||
|
processedCount++;
|
||||||
|
const senderName = msg.sender_full_name || msg.sender_email;
|
||||||
|
const senderId = msg.sender_id;
|
||||||
|
console.log(`[zulip-ext] DM from ${senderName} (id=${senderId}): ${msg.content.slice(0, 80)}...`);
|
||||||
|
injectIntoSession(msg, senderName, senderId, { type: "private", senderId, senderName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream messages: only respond to @all-bots (ADR-006)
|
||||||
|
if (msg.type === "stream" && isAllBotsMention(msg.content)) {
|
||||||
|
processedCount++;
|
||||||
|
const streamName = typeof msg.display_recipient === "string"
|
||||||
|
? msg.display_recipient
|
||||||
|
: "unknown";
|
||||||
|
const topic = msg.subject || "general";
|
||||||
|
console.log(`[zulip-ext] @all-bots in #${streamName} > ${topic}`);
|
||||||
|
injectIntoSession(msg, msg.sender_full_name || msg.sender_email, msg.sender_id, {
|
||||||
|
type: "stream",
|
||||||
|
streamName,
|
||||||
|
topic,
|
||||||
|
streamId: msg.stream_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polling loop.
|
||||||
|
*/
|
||||||
|
async function startPolling(): Promise<void> {
|
||||||
|
console.log(`[zulip-ext] Connecting to ${config.zulip.site} as ${config.zulip.email}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
queue = await createZulipQueue(config);
|
||||||
|
connected = true;
|
||||||
|
retryCount = 0;
|
||||||
|
lastError = null;
|
||||||
|
|
||||||
|
console.log(`[zulip-ext] Connected, queue: ${queue.queueId} (last_event_id=${queue.lastEventId})`);
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (!queue) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await queue.poll();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
await processEvent(event);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
// Check if queue was deregistered (need to reconnect)
|
||||||
|
if (errMsg.includes("BAD_EVENT_QUEUE_ID") || errMsg.includes("queue_id")) {
|
||||||
|
console.log(`[zulip-ext] Queue expired, reconnecting...`);
|
||||||
|
connected = false;
|
||||||
|
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
|
||||||
|
setTimeout(() => startPolling(), config.retry_delay_ms ?? 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network errors — retry on next poll interval
|
||||||
|
lastError = errMsg;
|
||||||
|
console.error(`[zulip-ext] Poll error: ${errMsg}`);
|
||||||
|
}
|
||||||
|
}, config.poll_interval_ms ?? 3000);
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
lastError = errMsg;
|
||||||
|
console.error(`[zulip-ext] Connection failed: ${errMsg}`);
|
||||||
|
|
||||||
|
if (retryCount < (config.max_retries ?? 3)) {
|
||||||
|
retryCount++;
|
||||||
|
const delay = (config.retry_delay_ms ?? 5000) * retryCount;
|
||||||
|
console.log(`[zulip-ext] Retrying in ${delay / 1000}s (attempt ${retryCount})...`);
|
||||||
|
|
||||||
|
setTimeout(() => startPolling(), delay);
|
||||||
|
} else {
|
||||||
|
console.error(`[zulip-ext] Max retries (${config.max_retries}) reached. Giving up.`);
|
||||||
|
lastError = `Connection failed after ${config.max_retries} retries: ${errMsg}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Extension lifecycle
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event) => {
|
||||||
|
console.log(`[zulip-ext] Starting Zulip extension for ${config.agent.name} (${config.zulip.email})`);
|
||||||
|
|
||||||
|
if (!healthServer) {
|
||||||
|
healthServer = startHealthServer(config.health_port ?? 9200, getState);
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Streaming + steering lifecycle
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* turn_start fires when a new LLM turn begins. Mark the agent as busy
|
||||||
|
* so subsequent Zulip DMs are steered instead of starting new turns.
|
||||||
|
*/
|
||||||
|
pi.on("turn_start", async () => {
|
||||||
|
isAgentBusy = true;
|
||||||
|
streamingAccumulator = "";
|
||||||
|
streamingReplyId = pendingZulipReplies.length > 0 ? pendingZulipReplies[0].id : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* message_update fires on each streaming token. Accumulate partial text
|
||||||
|
* and periodically edit the Zulip placeholder message so the user sees
|
||||||
|
* the response come in live.
|
||||||
|
*/
|
||||||
|
pi.on("message_update", async (event) => {
|
||||||
|
const msg = event.message as any;
|
||||||
|
const content = msg.content;
|
||||||
|
let partialText = "";
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
partialText = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
partialText = (content as Array<{ type: string; text: string }>)
|
||||||
|
.filter((c) => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
streamingAccumulator = partialText;
|
||||||
|
|
||||||
|
if (!streamingTimer && streamingAccumulator.length > 10) {
|
||||||
|
streamingTimer = setTimeout(async () => {
|
||||||
|
streamingTimer = null;
|
||||||
|
if (!streamingAccumulator || streamingReplyId === null) return;
|
||||||
|
|
||||||
|
const pending = pendingZulipReplies.find((r) => r.id === streamingReplyId);
|
||||||
|
if (!pending || !pending.zulipMessageId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const MAX_PREVIEW = 9500;
|
||||||
|
const preview = streamingAccumulator.length > MAX_PREVIEW
|
||||||
|
? streamingAccumulator.slice(0, MAX_PREVIEW) + "\n\n_… still generating…_"
|
||||||
|
: streamingAccumulator;
|
||||||
|
await queue!.editMessage(pending.zulipMessageId, preview);
|
||||||
|
} catch {
|
||||||
|
// Non-critical during streaming
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* agent_end captures the final LLM response and relays it back to Zulip.
|
||||||
|
* If we sent a placeholder message earlier, edit it with the final content
|
||||||
|
* (instead of sending a new message) for a seamless streaming experience.
|
||||||
|
*/
|
||||||
|
pi.on("agent_end", async (event) => {
|
||||||
|
isAgentBusy = false;
|
||||||
|
|
||||||
|
if (streamingTimer) {
|
||||||
|
clearTimeout(streamingTimer);
|
||||||
|
streamingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingZulipReplies.length === 0) return;
|
||||||
|
|
||||||
|
const reply = pendingZulipReplies.shift()!;
|
||||||
|
|
||||||
|
// Extract final assistant response text
|
||||||
|
const allMsgs = event.messages as unknown as Array<Record<string, unknown>>;
|
||||||
|
const assistantMsgs = allMsgs.filter((m) => m.role === "assistant");
|
||||||
|
const lastAssistant = assistantMsgs[assistantMsgs.length - 1];
|
||||||
|
let responseText = "";
|
||||||
|
|
||||||
|
if (lastAssistant) {
|
||||||
|
const content = lastAssistant.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
responseText = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
responseText = (content as Array<{ type: string; text: string }>)
|
||||||
|
.filter((c) => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop typing indicator
|
||||||
|
if (reply.senderId) {
|
||||||
|
queue!.sendTypingNotification([reply.senderId], "stop").catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseText.trim()) {
|
||||||
|
console.log(`[zulip-ext] No assistant response for [${reply.id}] from ${reply.senderName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ZULIP_MSG = 10000;
|
||||||
|
const truncated = responseText.length > MAX_ZULIP_MSG
|
||||||
|
? responseText.slice(0, MAX_ZULIP_MSG) + "\n\n[...truncated at Zulip limit, see session for full output]"
|
||||||
|
: responseText;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (reply.zulipMessageId) {
|
||||||
|
// Edit the placeholder with the final response (streaming UX)
|
||||||
|
await queue!.editMessage(reply.zulipMessageId, truncated);
|
||||||
|
console.log(`[zulip-ext] Finalized [${reply.id}] for ${reply.senderName} (${truncated.length} chars)`);
|
||||||
|
} else {
|
||||||
|
// No placeholder — send as new message
|
||||||
|
const target = reply.type === "private"
|
||||||
|
? { type: "private" as const, to: reply.senderId! }
|
||||||
|
: { type: "stream" as const, to: reply.streamId ?? reply.streamName!, subject: reply.topic! };
|
||||||
|
await queue!.sendMessage({ ...target, content: truncated });
|
||||||
|
console.log(`[zulip-ext] Replied to [${reply.id}] ${reply.senderName} (${truncated.length} chars)`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[zulip-ext] Failed to finalize reply [${reply.id}]: ${errMsg}`);
|
||||||
|
lastError = errMsg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async () => {
|
||||||
|
console.log(`[zulip-ext] Shutting down...`);
|
||||||
|
|
||||||
|
if (streamingTimer) {
|
||||||
|
clearTimeout(streamingTimer);
|
||||||
|
streamingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthServer) {
|
||||||
|
healthServer.close();
|
||||||
|
healthServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connected = false;
|
||||||
|
queue = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register /zulip-status command for diagnostics
|
||||||
|
pi.registerCommand("zulip-status", {
|
||||||
|
description: "Show Zulip extension status and stats",
|
||||||
|
handler: async (_args: unknown, ctx) => {
|
||||||
|
const state = getState();
|
||||||
|
const lines: string[] = [
|
||||||
|
"=== Zulip Extension Status ===",
|
||||||
|
"",
|
||||||
|
`Agent: ${state.agent}`,
|
||||||
|
`Email: ${state.email}`,
|
||||||
|
`Server: ${state.site}`,
|
||||||
|
`Owner: ${state.is_owner}`,
|
||||||
|
`Connected: ${state.connected ? "✅ Yes" : "❌ No"}`,
|
||||||
|
`Queue ID: ${state.queue_id ?? "N/A"}`,
|
||||||
|
`Last Event ID: ${state.last_event_id ?? "N/A"}`,
|
||||||
|
`Messages: ${state.messages_processed} processed`,
|
||||||
|
`Retries: ${state.retry_count}`,
|
||||||
|
`Health Port: :${config.health_port ?? 9200}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (state.last_error) {
|
||||||
|
lines.push("", `Last Error: ${state.last_error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("", "ADRs followed: DM-first (ADR-001), DM-routing (ADR-002), @all-bots content (ADR-006)");
|
||||||
|
|
||||||
|
ctx.ui.notify(lines.join("\n"), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register zulip-send-test command
|
||||||
|
pi.registerCommand("zulip-send-test", {
|
||||||
|
description: "Send a test message to Zulip user: /zulip-send-test <email> <message>",
|
||||||
|
handler: async (args: string, ctx) => {
|
||||||
|
const parts = args.trim().match(/^([^\s]+)\s+(.+)$/);
|
||||||
|
const targetEmail = parts?.[1] ?? config.agent.owner_email;
|
||||||
|
const message = parts?.[2] ?? "Test message from pi Zulip extension";
|
||||||
|
|
||||||
|
if (!queue) {
|
||||||
|
ctx.ui.notify("Zulip queue not connected. Use /zulip-status to check.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queue.sendMessage({
|
||||||
|
type: "private",
|
||||||
|
to: targetEmail,
|
||||||
|
content: message,
|
||||||
|
});
|
||||||
|
ctx.ui.notify(`Test message sent to ${targetEmail}`, "info");
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
ctx.ui.notify(`Failed to send: ${errMsg}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
// Type declarations for zulip-js (no @types available)
|
||||||
|
declare module "zulip-js" {
|
||||||
|
interface ZulipClient {
|
||||||
|
queues: {
|
||||||
|
register(params: {
|
||||||
|
event_types: string[];
|
||||||
|
narrow?: Array<Array<string | number>>;
|
||||||
|
}): Promise<{ queue_id: string; last_event_id: number }>;
|
||||||
|
};
|
||||||
|
users: {
|
||||||
|
me: {
|
||||||
|
get(): Promise<any>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
messages: {
|
||||||
|
store: {
|
||||||
|
send(rawContent: string): Promise<any>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
events: {
|
||||||
|
retrieve(params: {
|
||||||
|
queue_id: string;
|
||||||
|
last_event_id: number;
|
||||||
|
dont_block?: boolean;
|
||||||
|
}): Promise<{ events: any[]; result?: string; msg?: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function zulip(config: {
|
||||||
|
username: string;
|
||||||
|
apiKey: string;
|
||||||
|
realm: string;
|
||||||
|
}): Promise<ZulipClient>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .adapter import register
|
||||||
|
|
||||||
|
__all__ = ["register"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
name: zulip-platform
|
||||||
|
label: Zulip
|
||||||
|
kind: platform
|
||||||
|
version: 1.0.0
|
||||||
|
description: >
|
||||||
|
Zulip messaging platform adapter for Hermes Agent. Connects to a Zulip
|
||||||
|
server via event queue polling, processes DMs and @mentions, and sends
|
||||||
|
replies with placeholder→edit streaming. Uses the official zulip-js
|
||||||
|
HTTP API pattern (polling, not WebSockets) — lightweight, no external
|
||||||
|
SDK beyond httpx.
|
||||||
|
|
||||||
|
author: Syslog Solution LLC
|
||||||
|
|
||||||
|
requires_env:
|
||||||
|
- name: ZULIP_SITE
|
||||||
|
description: "Zulip server URL (e.g. https://chat.sysloggh.net)"
|
||||||
|
prompt: "Zulip server URL"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_EMAIL
|
||||||
|
description: "Bot email address (e.g. tanko-bot@chat.sysloggh.net)"
|
||||||
|
prompt: "Zulip bot email"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_API_KEY
|
||||||
|
description: "Zulip bot API key"
|
||||||
|
prompt: "Zulip API key"
|
||||||
|
password: true
|
||||||
|
|
||||||
|
optional_env:
|
||||||
|
- name: ZULIP_STREAM
|
||||||
|
description: "Primary stream to subscribe to (default: agent-hub)"
|
||||||
|
prompt: "Zulip stream name"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_ALL_BOTS_USER_ID
|
||||||
|
description: "User ID of the @all-bots user (default: 1)"
|
||||||
|
prompt: "All Bots user ID"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_AGENT_NAME
|
||||||
|
description: "Agent display name for logging (default: hermes-agent)"
|
||||||
|
prompt: "Agent name"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_OWNER_EMAIL
|
||||||
|
description: "Owner email for private topic ACL"
|
||||||
|
prompt: "Owner email"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_POLL_INTERVAL
|
||||||
|
description: "Event poll interval in seconds (default: 3)"
|
||||||
|
prompt: "Poll interval (seconds)"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_HOME_CHANNEL
|
||||||
|
description: "Default recipient for cron / notification delivery"
|
||||||
|
prompt: "Home channel (email or stream:topic)"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_HOME_CHANNEL_NAME
|
||||||
|
description: "Human label for the home channel"
|
||||||
|
prompt: "Home channel display name"
|
||||||
|
password: false
|
||||||
+185
-83
@@ -1,32 +1,56 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# deploy.sh GitOps deployment for zulip-platform-plugins
|
# deploy.sh — GitOps deployment for zulip-platform-plugins
|
||||||
# Usage: ./deploy.sh <tag|branch> [--ct=<agent>] [--dry-run]
|
#
|
||||||
# ./deploy.sh v1.0.0 # Deploy to all 6 CTs
|
# Supports two deployment modes:
|
||||||
# ./deploy.sh main # Deploy latest (staging only!)
|
# LEGACY: /opt/hermes-zulip-plugin/ + systemctl restart zulip-plugin
|
||||||
# ./deploy.sh --ct=tanko v1.0.0 # Deploy to single CT
|
# NATIVE: ~/.hermes/plugins/platforms/zulip/ + hermes gateway restart
|
||||||
# ./deploy.sh --dry-run v1.0.0 # Preview deployment without making changes
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh <tag|branch> # Deploy all agents (default: native)
|
||||||
|
# ./deploy.sh --mode=native v1.0.0 # Hermes native plugin (new)
|
||||||
|
# ./deploy.sh --mode=legacy v1.0.0 # Old systemd service (deprecated)
|
||||||
|
# ./deploy.sh --ct=tanko v1.0.0 # Single agent
|
||||||
|
# ./deploy.sh --dry-run v1.0.0 # Preview only
|
||||||
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DEPLOY_LOG="deploy.log"
|
DEPLOY_LOG="deploy.log"
|
||||||
TAG=""
|
TAG=""
|
||||||
SINGLE_CT=""
|
SINGLE_CT=""
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
DEPLOY_MODE="native" # default to new Hermes native plugin
|
||||||
|
|
||||||
# Parse args
|
# Parse args
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--ct=*) SINGLE_CT="${arg#--ct=}" ;;
|
--ct=*) SINGLE_CT="${arg#--ct=}" ;;
|
||||||
--dry-run) DRY_RUN=true ;;
|
--dry-run) DRY_RUN=true ;;
|
||||||
|
--mode=*) DEPLOY_MODE="${arg#--mode=}" ;;
|
||||||
*) TAG="$arg" ;;
|
*) TAG="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$TAG" ]]; then
|
if [[ -z "$TAG" ]]; then
|
||||||
echo "Usage: $0 <tag|branch> [--ct=<agent>] [--dry-run]"
|
echo "Usage: $0 <tag|branch> [--ct=<agent>] [--mode=native|legacy] [--dry-run]"
|
||||||
|
echo ""
|
||||||
|
echo " --ct=<agent> Deploy to single agent (tanko, mumuni, etc.)"
|
||||||
|
echo " --mode=native Hermes native plugin at ~/.hermes/plugins/ (default)"
|
||||||
|
echo " --mode=legacy Old systemd service at /opt/hermes-zulip-plugin/"
|
||||||
|
echo " --dry-run Preview deployment without making changes"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " ./deploy.sh v1.0.0 # Deploy native to all agents"
|
||||||
|
echo " ./deploy.sh --ct=tanko v1.0.0 # Deploy native to Tanko only"
|
||||||
|
echo " ./deploy.sh --mode=legacy v0.9.0 # Deploy legacy to all"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Agent CT registry (must match CONTEXT.md)
|
if [[ "$DEPLOY_MODE" != "native" && "$DEPLOY_MODE" != "legacy" ]]; then
|
||||||
|
echo "ERROR: --mode must be 'native' or 'legacy'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Agent Registry (must match docs/CONTEXT.md) ──────────────────────
|
||||||
declare -A AGENTS=(
|
declare -A AGENTS=(
|
||||||
["tanko"]="amdpve CT 112"
|
["tanko"]="amdpve CT 112"
|
||||||
["mumuni"]="minipve CT 114"
|
["mumuni"]="minipve CT 114"
|
||||||
@@ -36,32 +60,31 @@ declare -A AGENTS=(
|
|||||||
["abiba"]="amdpve CT 100"
|
["abiba"]="amdpve CT 100"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Platform paths per agent type
|
# Native Hermes plugin path (~/.hermes/plugins/platforms/zulip/)
|
||||||
declare -A PLUGIN_PATHS=(
|
NATIVE_PLUGIN_SRC="plugins/platforms/zulip"
|
||||||
|
|
||||||
|
# Legacy paths (DEPRECATED — systemd zulip-plugin service)
|
||||||
|
declare -A LEGACY_PATHS=(
|
||||||
["tanko"]="/opt/hermes-zulip-plugin"
|
["tanko"]="/opt/hermes-zulip-plugin"
|
||||||
["mumuni"]="/opt/hermes-zulip-plugin"
|
["mumuni"]="/opt/hermes-zulip-plugin"
|
||||||
["koonimo"]="/opt/hermes-zulip-plugin"
|
["koonimo"]="/opt/hermes-zulip-plugin"
|
||||||
["koby\"]="/opt/hermes-zulip-plugin"
|
["koby"]="/opt/hermes-zulip-plugin"
|
||||||
["kagentz\"]="/opt/agent-zero-plugin"
|
["kagentz"]="/opt/agent-zero-plugin"
|
||||||
["abiba\"]="/root/.pi/agent/extensions/zulip.ts"
|
["abiba"]="/root/.pi/agent/extensions/zulip.ts"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Correcting the backslash artifacts from the original file
|
# Service names for legacy mode
|
||||||
PLUGIN_PATHS["koby"]="/opt/hermes-zulip-plugin"
|
declare -A LEGACY_SERVICES=(
|
||||||
PLUGIN_PATHS["kagentz"]="/opt/agent-zero-plugin"
|
|
||||||
PLUGIN_PATHS["abiba"]="/root/.pi/agent/extensions/zulip.ts"
|
|
||||||
|
|
||||||
declare -A SERVICE_NAMES=(
|
|
||||||
["tanko"]="zulip-plugin"
|
["tanko"]="zulip-plugin"
|
||||||
["mumuni"]="zulip-plugin"
|
["mumuni"]="zulip-plugin"
|
||||||
["koonimo"]="zulip-plugin"
|
["koonimo"]="zulip-plugin"
|
||||||
["koby"]="zulip-plugin"
|
["koby"]="zulip-plugin"
|
||||||
["kagentz"]="zulip-plugin"
|
["kagentz"]="zulip-plugin"
|
||||||
["abiba"]="pi" # pi reload, not systemctl
|
["abiba"]="pi"
|
||||||
)
|
)
|
||||||
|
|
||||||
GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git"
|
|
||||||
HEALTH_PORT=9200
|
HEALTH_PORT=9200
|
||||||
|
GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
local prefix=""
|
local prefix=""
|
||||||
@@ -69,90 +92,169 @@ log() {
|
|||||||
echo "${prefix}[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$DEPLOY_LOG"
|
echo "${prefix}[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$DEPLOY_LOG"
|
||||||
}
|
}
|
||||||
|
|
||||||
deploy_agent() {
|
# ── Deployment Functions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
deploy_native() {
|
||||||
local agent="$1"
|
local agent="$1"
|
||||||
local ct_info="${AGENTS[$agent]}"
|
local plugin_dir="$HOME/.hermes/plugins/platforms/zulip"
|
||||||
local plugin_path="${PLUGIN_PATHS[$agent]}"
|
|
||||||
local service="${SERVICE_NAMES[$agent]}"
|
|
||||||
|
|
||||||
log "=== Deploying $agent ($ct_info) @ $TAG ==="
|
log "📦 Deploying $agent: native Hermes plugin -> $plugin_dir"
|
||||||
log "Path: $plugin_path"
|
|
||||||
|
|
||||||
# 1. Git pull & checkout tag
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
if [[ "$DRY_RUN" != "true" ]]; then
|
log " Would copy $NATIVE_PLUGIN_SRC/{adapter.py,__init__.py,plugin.yaml} -> $plugin_dir/"
|
||||||
cd "$plugin_path" || { log "ERROR: $agent path $plugin_path not found"; return 1; }
|
log " Would run: hermes gateway restart"
|
||||||
git fetch --tags origin
|
return 0
|
||||||
git checkout "$TAG"
|
|
||||||
else
|
|
||||||
log "Skipping Git checkout (Dry Run)"
|
|
||||||
fi
|
|
||||||
log "$agent: checked out $TAG"
|
|
||||||
|
|
||||||
# 2. Install dependencies (platform-specific)
|
|
||||||
if [[ "$DRY_RUN" != "true" ]]; then
|
|
||||||
case "$agent" in
|
|
||||||
tanko|mumuni|koonimo|koby)
|
|
||||||
pip install -r requirements.txt --quiet
|
|
||||||
;;
|
|
||||||
kagentz)
|
|
||||||
pip install -r requirements.txt --quiet
|
|
||||||
;;
|
|
||||||
abiba)
|
|
||||||
log "$agent: pi extension skipping pip install"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
log "Skipping dependency installation (Dry Run)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Restart service
|
# Create plugin directory
|
||||||
if [[ "$DRY_RUN" != "true" ]]; then
|
mkdir -p "$plugin_dir"
|
||||||
case "$agent" in
|
|
||||||
abiba)
|
# Copy plugin files
|
||||||
log "$agent: triggering /reload"
|
cp "$NATIVE_PLUGIN_SRC/adapter.py" "$plugin_dir/"
|
||||||
;;
|
cp "$NATIVE_PLUGIN_SRC/__init__.py" "$plugin_dir/"
|
||||||
*)
|
cp "$NATIVE_PLUGIN_SRC/plugin.yaml" "$plugin_dir/"
|
||||||
systemctl restart "$service"
|
|
||||||
log "$agent: restarted $service"
|
log " Copied plugin files to $plugin_dir/"
|
||||||
;;
|
ls -la "$plugin_dir/"
|
||||||
esac
|
|
||||||
|
# Restart Hermes Gateway to load the plugin
|
||||||
|
if command -v hermes &>/dev/null; then
|
||||||
|
log " Restarting Hermes Gateway..."
|
||||||
|
hermes gateway restart
|
||||||
|
log " Hermes Gateway restarted"
|
||||||
else
|
else
|
||||||
log "Skipping service restart (Dry Run)"
|
log " ⚠️ 'hermes' command not found — manual restart needed"
|
||||||
|
log " Run: hermes gateway restart"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Health check
|
# Health check
|
||||||
log "Waiting for service to stabilize..."
|
log " Waiting for service to stabilize..."
|
||||||
sleep 5
|
sleep 5
|
||||||
if [[ "$DRY_RUN" != "true" ]]; then
|
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
|
||||||
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
|
log " ✅ Health check passed (port $HEALTH_PORT)"
|
||||||
log "OK: $agent health check passed"
|
|
||||||
else
|
|
||||||
log "ERROR: $agent health check failed check logs"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
log "Skipping health check (Dry Run)"
|
log " ⚠️ Health check on port $HEALTH_PORT not responding"
|
||||||
|
log " Check Gateway logs for plugin load errors"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
log "✅ $agent: native deployment complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Main ---\
|
deploy_legacy() {
|
||||||
log "Deploy started target: $TAG (Dry Run: $DRY_RUN)"
|
local agent="$1"
|
||||||
|
local plugin_path="${LEGACY_PATHS[$agent]}"
|
||||||
|
local service="${LEGACY_SERVICES[$agent]}"
|
||||||
|
|
||||||
|
log "📦 Deploying $agent: LEGACY mode -> $plugin_path"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
|
log " Would git checkout $TAG in $plugin_path"
|
||||||
|
log " Would install deps + restart $service"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Git checkout
|
||||||
|
if [[ ! -d "$plugin_path" ]]; then
|
||||||
|
log "❌ Path $plugin_path not found for $agent"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$plugin_path"
|
||||||
|
git fetch --tags origin
|
||||||
|
git checkout "$TAG"
|
||||||
|
|
||||||
|
log " Checked out $TAG in $plugin_path"
|
||||||
|
|
||||||
|
# Install deps
|
||||||
|
if [[ -f "requirements.txt" ]]; then
|
||||||
|
pip install -r requirements.txt --quiet
|
||||||
|
log " Dependencies installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart service
|
||||||
|
if systemctl list-units --full -all 2>/dev/null | grep -q "$service"; then
|
||||||
|
systemctl restart "$service"
|
||||||
|
log " Restarted $service"
|
||||||
|
else
|
||||||
|
log " ⚠️ Service $service not found — manual restart needed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
sleep 5
|
||||||
|
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
|
||||||
|
log " ✅ Health check passed"
|
||||||
|
else
|
||||||
|
log " ⚠️ Health check failed — check logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "✅ $agent: legacy deployment complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Verify function ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
verify_deployment() {
|
||||||
|
local agent="$1"
|
||||||
|
|
||||||
|
log "🔍 Verifying $agent deployment..."
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "native" ]]; then
|
||||||
|
local plugin_dir="$HOME/.hermes/plugins/platforms/zulip"
|
||||||
|
if [[ -f "$plugin_dir/adapter.py" && -f "$plugin_dir/plugin.yaml" ]]; then
|
||||||
|
log " ✅ Plugin files present in $plugin_dir"
|
||||||
|
log " adapter.py: $(wc -l < "$plugin_dir/adapter.py") lines"
|
||||||
|
log " plugin.yaml: $(wc -l < "$plugin_dir/plugin.yaml") lines"
|
||||||
|
else
|
||||||
|
log " ❌ Plugin files missing in $plugin_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log " ✅ $agent verification complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log "🚀 Deploy started — tag: $TAG, mode: $DEPLOY_MODE, dry-run: $DRY_RUN"
|
||||||
|
|
||||||
if [[ -n "$SINGLE_CT" ]]; then
|
if [[ -n "$SINGLE_CT" ]]; then
|
||||||
deploy_agent "$SINGLE_CT"
|
if [[ -z "${AGENTS[$SINGLE_CT]:-}" ]]; then
|
||||||
|
log "❌ Unknown agent: $SINGLE_CT"
|
||||||
|
log " Known agents: ${!AGENTS[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "--- Deploying single agent: $SINGLE_CT ---"
|
||||||
|
if [[ "$DEPLOY_MODE" == "native" ]]; then
|
||||||
|
deploy_native "$SINGLE_CT"
|
||||||
|
else
|
||||||
|
deploy_legacy "$SINGLE_CT"
|
||||||
|
fi
|
||||||
|
verify_deployment "$SINGLE_CT"
|
||||||
else
|
else
|
||||||
FAILED=""
|
FAILED=""
|
||||||
for agent in tanko mumuni koonimo koby kagentz abiba; do
|
for agent in "${!AGENTS[@]}"; do
|
||||||
if ! deploy_agent "$agent"; then
|
echo ""
|
||||||
FAILED="$FAILED $agent"
|
if [[ "$DEPLOY_MODE" == "native" ]]; then
|
||||||
|
if deploy_native "$agent"; then
|
||||||
|
verify_deployment "$agent" || FAILED="$FAILED $agent"
|
||||||
|
else
|
||||||
|
FAILED="$FAILED $agent"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if deploy_legacy "$agent"; then
|
||||||
|
verify_deployment "$agent" || FAILED="$FAILED $agent"
|
||||||
|
else
|
||||||
|
FAILED="$FAILED $agent"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
log "=== Deploy complete ==="
|
log "=== Deploy complete ==="
|
||||||
if [[ -n "$FAILED" ]]; then
|
if [[ -n "$FAILED" ]]; then
|
||||||
log "FAILED:$FAILED"
|
log "❌ FAILED:$FAILED"
|
||||||
log "Run rollback: ./scripts/rollback.sh <previous-tag>"
|
log " Run rollback: ./scripts/rollback.sh <previous-tag>"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
log "All 6 agents deployed successfully."
|
log "✅ All agents deployed successfully."
|
||||||
|
log " Next: monitor #agent-hub for agent responses"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# verify-deployment.sh — Check if the Hermes Zulip native plugin is properly deployed
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./verify-deployment.sh # Check local agent
|
||||||
|
# ./verify-deployment.sh --ct=tanko # Check specific agent
|
||||||
|
# ./verify-deployment.sh --all # Check all reachable agents
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PLUGIN_DIR="$HOME/.hermes/plugins/platforms/zulip"
|
||||||
|
HEALTH_PORT=9200
|
||||||
|
|
||||||
|
echo "🔍 Zulip Plugin Deployment Verification"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Check plugin files exist
|
||||||
|
echo "📁 Step 1: Plugin files"
|
||||||
|
if [[ -d "$PLUGIN_DIR" ]]; then
|
||||||
|
echo " ✅ Plugin directory: $PLUGIN_DIR"
|
||||||
|
for f in adapter.py __init__.py plugin.yaml; do
|
||||||
|
if [[ -f "$PLUGIN_DIR/$f" ]]; then
|
||||||
|
echo " ✅ $f — $(wc -l < "$PLUGIN_DIR/$f") lines"
|
||||||
|
else
|
||||||
|
echo " ❌ $f — MISSING"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo " ❌ Plugin directory NOT FOUND at $PLUGIN_DIR"
|
||||||
|
echo " → Install: ./scripts/deploy.sh --mode=native v1.0.0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Check Hermes Gateway is running
|
||||||
|
echo "🔧 Step 2: Hermes Gateway"
|
||||||
|
if command -v hermes &>/dev/null; then
|
||||||
|
echo " ✅ 'hermes' command found"
|
||||||
|
if hermes gateway status 2>/dev/null | grep -qi "running"; then
|
||||||
|
echo " ✅ Hermes Gateway is running"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Hermes Gateway status unknown — check manually"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ❌ 'hermes' command not found"
|
||||||
|
echo " → Is Hermes Agent installed?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. Check health endpoint
|
||||||
|
echo "❤️ Step 3: Health endpoint"
|
||||||
|
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
|
||||||
|
echo " ✅ Health endpoint responds on port $HEALTH_PORT"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Health endpoint not responding on port $HEALTH_PORT"
|
||||||
|
echo " → The plugin may not have started yet"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. Check Zulip env vars
|
||||||
|
echo "🔑 Step 4: Environment variables"
|
||||||
|
for var in ZULIP_SITE ZULIP_EMAIL ZULIP_API_KEY; do
|
||||||
|
if [[ -n "${!var:-}" ]]; then
|
||||||
|
val="${!var}"
|
||||||
|
if [[ "$var" == "ZULIP_API_KEY" ]]; then
|
||||||
|
echo " ✅ $var — [REDACTED]"
|
||||||
|
else
|
||||||
|
echo " ✅ $var — $val"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ❌ $var — NOT SET"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Overall
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
missing=0
|
||||||
|
[[ -d "$PLUGIN_DIR" ]] || missing=$((missing + 1))
|
||||||
|
command -v hermes &>/dev/null || missing=$((missing + 1))
|
||||||
|
[[ -n "${ZULIP_SITE:-}" && -n "${ZULIP_EMAIL:-}" && -n "${ZULIP_API_KEY:-}" ]] || missing=$((missing + 1))
|
||||||
|
|
||||||
|
if [[ "$missing" -eq 0 ]]; then
|
||||||
|
echo "✅ VERDICT: Plugin properly deployed"
|
||||||
|
echo " Send a DM to verify: @**${ZULIP_AGENT_NAME:-hermes-agent}** _hello_"
|
||||||
|
else
|
||||||
|
echo "⚠️ VERDICT: $missing issue(s) found — fix and re-verify"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user