/** * abiba-zulip-service — Standalone Zulip gateway for Abiba (pi agent) * * Replaces the pi extension model. Instead of injecting messages into pi's * session, this runs as an independent Node.js process that: * - Connects to Zulip via event queue polling * - Calls the harness inference API directly for each DM * - Maintains per-sender conversation memory * - Runs as a systemd service (survives pi shutdown) * * Config via env vars (see README or systemd service file). * * @see ADR-001: DM-first architecture * @see ADR-007: Platform-native plugin contracts */ import zulip, { ZulipClient } from "zulip-js"; import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // ── Types ───────────────────────────────────────────────────────────────── interface Config { zulip: { email: string; api_key: string; site: string; }; agent: { name: string; owner_email: string; }; harness: { url: string; api_key: string; model: string; max_tokens: number; }; health_port: number; poll_interval_ms: number; max_retries: number; retry_delay_ms: number; } interface ChatMessage { role: "system" | "user" | "assistant"; content: string; } interface PendingReply { id: number; zulipMessageId?: number; senderId: number; senderName: string; type: "private"; } interface ZulipQueue { client: ZulipClient; queueId: string; lastEventId: number; poll(): Promise; sendMessage(params: { type: string; to: string | number; subject?: string; content: string; }): Promise; editMessage(messageId: number, content: string): Promise; sendTypingNotification(userIds: number[], operation: string): Promise; } // ── Constants ────────────────────────────────────────────────────────────── const SYSTEM_PROMPT = `You are Abiba, an AI assistant operating in a homelab environment. You communicate through Zulip DMs. Be helpful, concise, and technically accurate. Your owner is Jerome.`; const PLACEHOLDERS = [ ":robot: _Processing your message..._", ":hourglass_flowing_sand: _Thinking..._", ":brain: _Generating response..._", ]; const MAX_CONVERSATION_HISTORY = 50; const MAX_ZULIP_MSG_LENGTH = 10000; const STREAMING_UPDATE_INTERVAL_MS = 2000; // ── Config loading ──────────────────────────────────────────────────────── function loadConfig(): Config { const email = process.env.ZULIP_EMAIL || ""; const apiKey = process.env.ZULIP_API_KEY || ""; const site = process.env.ZULIP_SITE || ""; if (!email || !apiKey || !site) { console.error( "[abiba-zulip] Missing required env vars: ZULIP_EMAIL, ZULIP_API_KEY, ZULIP_SITE" ); process.exit(1); } return { zulip: { email, api_key: apiKey, site }, agent: { name: process.env.AGENT_NAME || "abiba", owner_email: process.env.AGENT_OWNER_EMAIL || "jerome@sysloggh.com", }, harness: { url: process.env.HARNESS_URL || "http://192.168.68.116/v1/chat/completions", api_key: process.env.HARNESS_API_KEY || "sk-856ffb0bbb-e5aaf78b10054eca608f8fbcbd73a889", model: process.env.HARNESS_MODEL || "qwen3.6-35B-A3B", max_tokens: parseInt(process.env.HARNESS_MAX_TOKENS || "4096", 10), }, health_port: parseInt(process.env.HEALTH_PORT || "9200", 10), poll_interval_ms: parseInt(process.env.POLL_INTERVAL_MS || "3000", 10), max_retries: parseInt(process.env.MAX_RETRIES || "5", 10), retry_delay_ms: parseInt(process.env.RETRY_DELAY_MS || "5000", 10), }; } // ── Conversation memory ─────────────────────────────────────────────────── const conversations = new Map(); function getOrCreateConversation(senderEmail: string): ChatMessage[] { let history = conversations.get(senderEmail); if (!history) { history = [{ role: "system", content: SYSTEM_PROMPT }]; conversations.set(senderEmail, history); } return history; } function addToConversation( senderEmail: string, message: ChatMessage ): void { const history = getOrCreateConversation(senderEmail); history.push(message); // Trim if too long (keep system prompt) if (history.length > MAX_CONVERSATION_HISTORY) { const system = history[0]; history.splice(1, history.length - MAX_CONVERSATION_HISTORY); history.unshift(system); } } // ── Harness API ─────────────────────────────────────────────────────────── async function queryHarness( senderEmail: string, userMessage: string, config: Config ): Promise { const messages = getOrCreateConversation(senderEmail); messages.push({ role: "user", content: userMessage }); const response = await fetch(config.harness.url, { method: "POST", headers: { Authorization: `Bearer ${config.harness.api_key}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: config.harness.model, messages: messages, max_tokens: config.harness.max_tokens, stream: false, }), signal: AbortSignal.timeout(120_000), }); if (!response.ok) { const text = await response.text(); throw new Error(`Harness API returned ${response.status}: ${text.slice(0, 500)}`); } const data = await response.json(); const reply = data.choices?.[0]?.message?.content ?? "[No response generated]"; // Store assistant reply in conversation history messages.push({ role: "assistant", content: reply }); return reply; } // ── Zulip queue creation ────────────────────────────────────────────────── async function createZulipQueue(config: Config): Promise { const client = await zulip({ username: config.zulip.email, apiKey: config.zulip.api_key, realm: config.zulip.site, }); const queueRes: any = await client.queues.register({ event_types: ["message"], }); const queueId: string = queueRes.queue_id; let lastEventId: number = queueRes.last_event_id ?? -1; return { client, queueId, lastEventId, async poll() { 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: any = await res.json(); if (data.events && Array.isArray(data.events)) { for (const event of data.events) { if (event.id > lastEventId) { lastEventId = event.id; } } // Update the cached lastEventId so reconnections preserve progress this.lastEventId = lastEventId; return data.events.filter((e: any) => e.type === "message"); } return []; }, async sendMessage(params) { 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: any = await res.json(); return body.id; }, async editMessage(messageId, content) { 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( `[abiba-zulip] Edit message ${messageId} returned ${res.status}` ); } }, async sendTypingNotification(userIds, operation) { 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 server ───────────────────────────────────────────────────────── function startHealthServer(port: number, getState: () => any): http.Server { const server = http.createServer((req, res) => { if (req.url === "/health" || req.url === "/") { const state = getState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", ...state })); } else { res.writeHead(404); res.end("Not found"); } }); server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { console.warn( `[abiba-zulip] Port :${port} already in use — skipping health endpoint` ); } else { console.error(`[abiba-zulip] Health server error:`, err.message); } }); server.listen(port, "127.0.0.1", () => { console.log(`[abiba-zulip] Health endpoint on :${port}`); }); return server; } // ── Main ────────────────────────────────────────────────────────────────── async function main() { const config = loadConfig(); console.log(`[abiba-zulip] Starting Abiba Zulip Service v2.0.0`); console.log( `[abiba-zulip] Agent: ${config.agent.name}, Zulip: ${config.zulip.email} @ ${config.zulip.site}` ); console.log( `[abiba-zulip] Harness: ${config.harness.model} @ ${config.harness.url}` ); let queue: ZulipQueue | null = null; let connected = false; let retryCount = 0; let lastError: string | null = null; let messagesProcessed = 0; let pollTimer: ReturnType | null = null; let healthServer: http.Server | null = null; // Pending replies awaiting LLM response const pendingReplies: Map = new Map(); let replyIdCounter = 0; // Track which sender's DM is currently being processed (one at a time per sender) const processingSenders = new Set(); function getState() { return { connected, email: config.zulip.email, site: config.zulip.site, agent: config.agent.name, messages_processed: messagesProcessed, last_error: lastError, retry_count: retryCount, active_conversations: conversations.size, pending_replies: pendingReplies.size, }; } /** * Process a single DM: send placeholder → call harness → edit with response. */ async function processDM( senderEmail: string, senderId: number, senderName: string, content: string ): Promise { if (processingSenders.has(senderEmail)) { console.log( `[abiba-zulip] Already processing DM from ${senderName}, queueing...` ); // TODO: implement proper queue if needed return; } processingSenders.add(senderEmail); messagesProcessed++; try { // Send typing indicator queue! .sendTypingNotification([senderId], "start") .catch(() => {}); // Send placeholder const replyId = ++replyIdCounter; const placeholder = PLACEHOLDERS[replyId % PLACEHOLDERS.length]; let placeholderMsgId: number | undefined; try { placeholderMsgId = await queue!.sendMessage({ type: "private", to: senderId, content: placeholder, }); pendingReplies.set(replyId, { id: replyId, zulipMessageId: placeholderMsgId, senderId, senderName, type: "private", }); console.log( `[abiba-zulip] [${replyId}] Placeholder sent for ${senderName} (msg ${placeholderMsgId})` ); } catch { pendingReplies.set(replyId, { id: replyId, senderId, senderName, type: "private", }); console.log( `[abiba-zulip] [${replyId}] No placeholder for ${senderName}` ); } // Call harness API console.log( `[abiba-zulip] [${replyId}] Querying harness for ${senderName}...` ); const response = await queryHarness(senderEmail, content, config); // Stop typing queue! .sendTypingNotification([senderId], "stop") .catch(() => {}); // Truncate if needed const truncated = response.length > MAX_ZULIP_MSG_LENGTH ? response.slice(0, MAX_ZULIP_MSG_LENGTH) + "\n\n[...truncated at Zulip limit]" : response; // Send response const pending = pendingReplies.get(replyId); if (pending?.zulipMessageId) { await queue!.editMessage(pending.zulipMessageId, truncated); console.log( `[abiba-zulip] [${replyId}] Finalized for ${senderName} (${truncated.length} chars)` ); } else { await queue!.sendMessage({ type: "private", to: senderId, content: truncated, }); console.log( `[abiba-zulip] [${replyId}] Sent fresh for ${senderName} (${truncated.length} chars)` ); } pendingReplies.delete(replyId); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); console.error(`[abiba-zulip] Error processing DM from ${senderName}: ${errMsg}`); lastError = errMsg; // Try to send error message to user try { await queue!.sendMessage({ type: "private", to: senderId, content: ":warning: Sorry, I encountered an error processing your request. Please try again later.", }); } catch { // Best effort } } finally { processingSenders.delete(senderEmail); } } /** * Polling loop — connects, registers queue, polls for events. */ async function startPolling() { console.log( `[abiba-zulip] Connecting to ${config.zulip.site} as ${config.zulip.email}...` ); try { queue = await createZulipQueue(config); connected = true; retryCount = 0; lastError = null; console.log( `[abiba-zulip] Connected, queue: ${queue.queueId} (last_event_id=${queue.lastEventId})` ); // Start health server (on first connect) if (!healthServer) { healthServer = startHealthServer(config.health_port, getState); } // Poll loop pollTimer = setInterval(async () => { if (!queue) return; try { const events = await queue.poll(); for (const event of events) { const msg = event.message; if (!msg) continue; if (msg.sender_email === config.zulip.email) continue; if (msg.type !== "private") continue; // DM-first const content = (msg.content || "").trim(); if (!content) continue; console.log( `[abiba-zulip] DM from ${msg.sender_full_name || msg.sender_email}: ${content.slice(0, 80)}...` ); // Process asynchronously (don't block the poll loop) processDM( msg.sender_email, msg.sender_id, msg.sender_full_name || msg.sender_email, content ).catch((err) => console.error(`[abiba-zulip] processDM error:`, err) ); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); if ( errMsg.includes("BAD_EVENT_QUEUE_ID") || errMsg.includes("queue_id") ) { console.log(`[abiba-zulip] Queue expired, reconnecting...`); connected = false; if (pollTimer) clearInterval(pollTimer); pollTimer = null; setTimeout(() => startPolling(), config.retry_delay_ms); return; } lastError = errMsg; console.error(`[abiba-zulip] Poll error: ${errMsg}`); } }, config.poll_interval_ms); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); lastError = errMsg; console.error(`[abiba-zulip] Connection failed: ${errMsg}`); if (retryCount < config.max_retries) { retryCount++; const delay = config.retry_delay_ms * retryCount; console.log( `[abiba-zulip] Retrying in ${delay / 1000}s (attempt ${retryCount})...` ); setTimeout(() => startPolling(), delay); } else { console.error( `[abiba-zulip] Max retries (${config.max_retries}) reached. Giving up.` ); lastError = `Connection failed after ${config.max_retries} retries: ${errMsg}`; } } } // ── Graceful shutdown ────────────────────────────────────────────────── function shutdown(signal: string) { console.log(`\n[abiba-zulip] Received ${signal}. Shutting down...`); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (healthServer) { healthServer.close(); healthServer = null; } connected = false; queue = null; process.exit(0); } process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); // ── Start ────────────────────────────────────────────────────────────── await startPolling(); // Keep alive — idle loop console.log(`[abiba-zulip] Service running. Waiting for DMs...`); await new Promise(() => {}); // never resolves — process runs until killed } main().catch((err) => { console.error(`[abiba-zulip] Fatal error:`, err); process.exit(1); });