Compare commits

..
Author SHA1 Message Date
Abiba (pi) aeb79c6286 security: remove hardcoded abiba-bot credentials from CI workflow
Replaced manual 'git clone' with password in URL with actions/checkout@v4.
Runner already auto-checkouts the repo - manual clone was redundant.
Also fixed YAML syntax issues in Config validation and No secrets check steps.
Credentials were exposed in git history since initial commit.
2026-07-13 00:30:14 +00:00
Abiba (pi) 897e8d36e8 contract: add host + Health URL columns, use health_url param
- Added Host column (Abiba: 192.168.68.24, Hermes agents: 192.168.68.123)
- Changed Health Port → Health URL with full http://host:port/health URLs
- Updated Check 1 to reference {{health_url}} instead of {{health_port}}
- Added .gitignore for .agents/ (OpenProse run state)
2026-07-13 00:30:14 +00:00
Abiba (pi) 459815c0cc feat: /zulip-test command + standardized health schema v1
- Added /zulip-test self-test command (7 checks: queue, identity,
  @all-bots, health, poll loop, echo prevention, API send)
- Standardized health endpoint to zulip-health/v1 schema
  (nested zulip{} object, checks{} map, platform/agent metadata)
- Saved pi extension source to pi-zulip-extension/extension-src/
  (with all fixes: dynamic @all-bots, health sync, logging)
- Added extension src to repo for deployment tracking
- Updated vault with final contract status report
2026-07-13 00:30:14 +00:00
Abiba (pi) 773c67db8f contract: Zulip extension verification report + cross-platform contract
- Full contract verification for pi (TypeScript) and Hermes (Python) Zulip plugins
- Fixed @all-bots user_id (1→20) — now dynamically resolved from Zulip API
- Fixed last_event_id stale display in health endpoint (B2)
- Fixed display_recipient logging (B3)
- Added bot_user_id and all_bots_user_id to health endpoint
- Created OpenProse cross-platform verification contract (docs/contracts/)
- Created Hermes plugin deployment script (scripts/deploy-hermes-zulip.py)

Pi extension: 8/9 checks pass (LLM relay untested — no external DMs)
Hermes adapter: 9/10 checks pass (deployment pending)
2026-07-13 00:30:14 +00:00
Abiba (pi) 0c15159a5f merge: feat/zulip-streaming into master — _strip_html + streaming support 2026-07-08 17:57:17 +00:00
Abiba (pi) 19c52a9425 fix(zulip): account for TRUNCATION_NOTICE overhead in _truncate
The truncation notice '[...truncated at Zulip limit]' was appended AFTER
slicing at MAX_ZULIP_MESSAGE (10000), causing the final message to exceed
Zulip's API limit. This fix subtracts the notice length from the slice so
the total stays within bounds.
2026-07-08 17:57:14 +00:00
jerome c9a1717e12 fix(zulip): add _strip_html for slash command matching
Zulip delivers message content as HTML (<p>/approve</p>).
The gateway slash command parser expects plain text, so HTML
tags prevent command matching. This helper strips HTML tags
and decodes common entities (&amp;, &lt;, etc.).

Per contract: zulip-approval-fix.prose.md

Applied to: Mumuni (CT114), Tanko (CT112), Koby (CT111)
2026-07-08 03:18:03 -04:00
Abiba ca95d47dc8 feat: add edit_message + streaming support to Zulip adapter
Implements edit_message() using Zulip's PATCH /api/v1/messages/{id} API.
Enables the Hermes GatewayStreamConsumer to progressively update Zulip
messages during agent generation, giving users real-time visibility into
agent thinking via progressive edits.

Adds _api_patch() helper for PATCH HTTP method.
2026-07-06 00:01:00 +00:00
abiba-bot aaaa11a912 Merge pull request 'fix: Zulip attachment handling — event attachments + all file types' (#32) from fix/zulip-attachment-handling into main 2026-07-05 16:06:31 +00:00
Abiba b6fa3da540 fix: Zulip attachment handling — event attachments + all file types
hermes-zulip-plugin (Tanko/Mumuni):
  - Add _extract_event_attachments() to handle message.attachments from
    Zulip UI file uploads (previously only inline markdown links worked)
  - Add shared _cache_attachment() method for images/audio/documents
  - Refactor _extract_inline_media() to use _cache_attachment()
  - Fix connect() signature for hermes-agent 0.18.0 compat (add is_reconnect)

pi-zulip-extension (Abiba):
  - Extend attachment handling beyond images only: text files (decoded
    inline), PDFs (pdftotext extraction), binary files (metadata)
  - 50K char cap on text/PDF extraction to prevent context flooding
  - Classify attachments by extension (image/text/pdf/binary)

pi-mcp-extension (Abiba):
  - Detect bridge-side text truncation (… ellipsis marker)
  - Rebuild relay message display from structuredContent when truncated
  - Add rebuildRelayTextFromStructuredContent() helper

Config:
  - Add ZULIP_ROLE=router to ecosystem.abiba.config.cjs (was missing)
2026-07-05 16:03:32 +00:00
Abiba (pi) 15adb30bc7 fix: raise MAX_CONSECUTIVE_EMPTY_POLLS 20→500 to prevent idle reconnection cycling
Bots with no incoming messages were reconnecting every ~60 seconds
(20 polls × 3s interval). Raised to 500 polls (~25min) so idle bots
don't cycle. Connection recovers immediately on BAD_EVENT_QUEUE_ID
regardless of this counter.
2026-06-28 11:28:57 +00:00
Abiba (pi) faf9566332 test: final CI verification 2026-06-28 00:52:21 +00:00
Abiba (pi) b0aeb81caf chore: add CI status doc 2026-06-28 00:51:44 +00:00
abiba-bot 04f78f3f01 Merge pull request 'ci: simplify workflow + cleanup' (#30) from feat/ci-fix into main 2026-06-28 00:49:55 +00:00
Abiba (pi) 3fbd31fbff chore: cleanup test workflows 2026-06-28 00:48:01 +00:00
Abiba (pi) a5dc880af1 ci: simplify workflow 2026-06-28 00:47:23 +00:00
abiba-bot 9b9845fd14 Merge pull request 'ci: replace actions/checkout with native git clone' (#29) from feat/ci-fix into main
ci: replace actions/checkout with native git clone (#29)
2026-06-28 00:45:51 +00:00
Abiba (pi) 106048999f test: minimal workflow 2026-06-28 00:45:02 +00:00
Abiba (pi) 6afc46734a ci: debug checkout step 2026-06-28 00:43:43 +00:00
Abiba (pi) 9ee1919985 ci: add auth to git clone in workflows 2026-06-28 00:42:41 +00:00
4 changed files with 43 additions and 136 deletions
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
validate: validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
- run: python3 --version - run: python3 --version
- run: node --version - run: node --version
- run: echo "Runner works!" - run: echo "Runner works!"
- run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Python syntax check - name: Python syntax check
run: | run: |
@@ -45,8 +45,8 @@ print('✅ config.yaml.example valid')
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [validate] needs: [validate]
steps: steps:
- uses: actions/checkout@v4
- run: echo "🚀 Deploy tag $(echo $GITHUB_REF_NAME)" - run: echo "🚀 Deploy tag $(echo $GITHUB_REF_NAME)"
- run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to Tanko (canary) - name: Deploy to Tanko (canary)
run: ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 jerome@192.168.68.122 "cd /root && bash -s" < scripts/deploy.sh --ct=tanko --mode=native "$GITHUB_REF_NAME" 2>&1 || echo "⚠️ Tanko deploy skipped" run: ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 jerome@192.168.68.122 "cd /root && bash -s" < scripts/deploy.sh --ct=tanko --mode=native "$GITHUB_REF_NAME" 2>&1 || echo "⚠️ Tanko deploy skipped"
+4 -4
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git . run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Python syntax - name: Python syntax
run: | run: |
python3 -m py_compile plugins/platforms/zulip/adapter.py 2>/dev/null python3 -m py_compile plugins/platforms/zulip/adapter.py 2>/dev/null
@@ -37,7 +37,7 @@ jobs:
environment: canary environment: canary
steps: steps:
- name: Checkout - name: Checkout
run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git . run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to Tanko - name: Deploy to Tanko
env: env:
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
@@ -66,7 +66,7 @@ jobs:
environment: production environment: production
steps: steps:
- name: Checkout - name: Checkout
run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git . run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to all Hermes agents - name: Deploy to all Hermes agents
env: env:
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
@@ -97,7 +97,7 @@ jobs:
environment: agent-zero environment: agent-zero
steps: steps:
- name: Checkout - name: Checkout
run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git . run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to kagentz - name: Deploy to kagentz
env: env:
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
+33
View File
@@ -203,6 +203,22 @@ class ZulipAdapter(BasePlatformAdapter):
logger.error("Zulip POST %s network error: %s", path, exc) logger.error("Zulip POST %s network error: %s", path, exc)
return {"result": "error", "msg": str(exc)} return {"result": "error", "msg": str(exc)}
async def _api_patch(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
import aiohttp
url = f"{self._site}/api/v1/{path.lstrip('/')}"
try:
async with self._session.patch(
url, data=payload, auth=self._auth(),
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
data = await resp.json()
if resp.status >= 400:
logger.debug("Zulip PATCH %s -> %s: %s", path, resp.status, str(data)[:200])
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
logger.debug("Zulip PATCH %s network error: %s", path, exc)
return {"result": "error", "msg": str(exc)}
async def _api_delete(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: async def _api_delete(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
import aiohttp import aiohttp
url = f"{self._site}/api/v1/{path.lstrip('/')}" url = f"{self._site}/api/v1/{path.lstrip('/')}"
@@ -326,6 +342,23 @@ class ZulipAdapter(BasePlatformAdapter):
return SendResult(success=True, message_id=str(last_id) if last_id else None) return SendResult(success=True, message_id=str(last_id) if last_id else None)
async def edit_message(
self,
chat_id: str,
message_id: str,
content: str,
**kwargs,
) -> SendResult:
"""Edit a previously-sent message. Used by the gateway stream consumer
for progressive message updates during agent streaming."""
formatted = self.format_message(content)
data = await self._api_patch(f"messages/{message_id}", {"content": formatted})
if data.get("result") != "success":
msg = str(data.get("msg", "edit failed"))
logger.debug("Zulip edit_message(%s) -> %s", message_id, msg)
return SendResult(success=False, error=msg)
return SendResult(success=True, message_id=message_id)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
kind, to, topic = _parse_target(chat_id, self._default_topic) kind, to, topic = _parse_target(chat_id, self._default_topic)
if kind == "direct": if kind == "direct":
+4 -130
View File
@@ -43,7 +43,6 @@ import time
import uuid import uuid
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
try: try:
@@ -68,6 +67,7 @@ DEFAULT_STREAM = "agent-hub"
DEFAULT_ALL_BOTS_USER_ID = 1 DEFAULT_ALL_BOTS_USER_ID = 1
DEFAULT_POLL_INTERVAL = 3.0 DEFAULT_POLL_INTERVAL = 3.0
MAX_ZULIP_MESSAGE = 10000 MAX_ZULIP_MESSAGE = 10000
TRUNCATION_NOTICE = "\n\n[...truncated at Zulip limit]"
ECHO_TAG_PREFIX = "hermes-zulip-" ECHO_TAG_PREFIX = "hermes-zulip-"
RECONNECT_BACKOFF = [2, 5, 10, 30, 60] RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
DEDUP_WINDOW = 300 # 5 minutes DEDUP_WINDOW = 300 # 5 minutes
@@ -121,7 +121,7 @@ def _truncate(text: str, limit: int = MAX_ZULIP_MESSAGE) -> str:
"""Truncate to Zulip's message limit with notice.""" """Truncate to Zulip's message limit with notice."""
if len(text) <= limit: if len(text) <= limit:
return text return text
return text[:limit] + "\n\n[...truncated at Zulip limit]" return text[:limit - len(TRUNCATION_NOTICE)] + TRUNCATION_NOTICE
def _parse_zulip_timestamp(ts: Any) -> datetime: def _parse_zulip_timestamp(ts: Any) -> datetime:
@@ -882,116 +882,6 @@ class ZulipAdapter(BasePlatformAdapter):
reply_to_topic=topic, reply_to_topic=topic,
) )
# ------------------------------------------------------------------
# Media / attachment extraction (vision support)
# ------------------------------------------------------------------
async def _extract_inline_media(
self, content: str, media_urls: List[str], media_types: List[str],
) -> None:
"""Download Zulip ``/user_uploads/...`` attachments referenced in a
message so vision/transcription tools can read them locally.
Zulip renders uploaded files as ``<img src="/user_uploads/...">``
in HTML. After ``_strip_html()`` the markdown-style references
survive in the plain text. This method finds those references,
downloads the files, and caches them into Hermes media storage.
"""
for rel in re.findall(r"\]\((/user_uploads/[^)]+)\)", content):
dl_url = f"{self._site}{rel}"
try:
resp = await self._http_client.get(
dl_url,
headers={"Authorization": self._auth_header},
timeout=30.0,
)
if resp.status_code >= 400:
logger.debug(
"[%s] Inline media %s -> %d",
self.name, rel, resp.status_code,
)
continue
data = resp.content
mime = (
resp.headers.get("content-type", "application/octet-stream")
.split(";")[0]
.strip()
)
except Exception:
logger.debug("[%s] Failed to download inline media %s", self.name, rel)
continue
fname = rel.rsplit("/", 1)[-1] or "file"
await self._cache_attachment(data, fname, mime, media_urls, media_types)
async def _extract_event_attachments(
self, message: Dict[str, Any], media_urls: List[str], media_types: List[str],
) -> None:
"""Download files from Zulip's ``message.attachments`` field (UI file
uploads that Zulip sends as structured event data rather than inline
markdown links)."""
atts = message.get("attachments") or []
for att in atts:
path_id = att.get("path_id")
fname = att.get("name") or "file"
if not path_id:
continue
dl_url = f"{self._site}/api/v1/user_uploads/{path_id}"
try:
resp = await self._http_client.get(
dl_url,
headers={"Authorization": self._auth_header},
timeout=30.0,
)
if resp.status_code >= 400:
logger.debug(
"[%s] Event attachment %s -> %d",
self.name, fname, resp.status_code,
)
continue
data = resp.content
mime = (
resp.headers.get("content-type", "application/octet-stream")
.split(";")[0]
.strip()
)
except Exception:
logger.debug("[%s] Failed to download event attachment %s", self.name, fname)
continue
await self._cache_attachment(data, fname, mime, media_urls, media_types)
async def _cache_attachment(
self, data: bytes, fname: str, mime: str,
media_urls: List[str], media_types: List[str],
) -> None:
"""Cache a downloaded attachment into Hermes media storage so the
vision_analyze / ocr tools can access it via a local file path."""
try:
from gateway.platforms.base import (
cache_image_from_bytes,
cache_audio_from_bytes,
cache_document_from_bytes,
)
except ImportError:
logger.warning("[%s] cache_*_from_bytes not available in gateway", self.name)
return
ext = Path(fname).suffix
try:
if mime.startswith("image/"):
media_urls.append(cache_image_from_bytes(data, ext or ".png"))
media_types.append(mime)
elif mime.startswith("audio/"):
media_urls.append(cache_audio_from_bytes(data, ext or ".ogg"))
media_types.append(mime)
else:
media_urls.append(cache_document_from_bytes(data, fname))
media_types.append(mime)
except Exception as exc:
logger.warning("[%s] Failed to cache attachment %s: %s", self.name, fname, exc)
# ------------------------------------------------------------------
# Message routing
# ------------------------------------------------------------------
async def _route_message( async def _route_message(
self, self,
text: str, text: str,
@@ -1022,22 +912,8 @@ class ZulipAdapter(BasePlatformAdapter):
chat_id_alt=str(reply_to_id) if reply_to_id else None, chat_id_alt=str(reply_to_id) if reply_to_id else None,
) )
# Gen 5: Extract media/attachments so vision tools can read them # Derive message type
media_urls: List[str] = [] message_type = MessageType.TEXT
media_types: List[str] = []
await self._extract_inline_media(text, media_urls, media_types)
await self._extract_event_attachments(raw, media_urls, media_types)
# Derive message type — upgrade to PHOTO/VOICe/DOCUMENT when media present
if media_types:
if any(m.startswith("image/") for m in media_types):
message_type = MessageType.PHOTO
elif any(m.startswith("audio/") for m in media_types):
message_type = MessageType.VOICE
else:
message_type = MessageType.DOCUMENT
else:
message_type = MessageType.TEXT
message_event = MessageEvent( message_event = MessageEvent(
text=text, text=text,
@@ -1046,8 +922,6 @@ class ZulipAdapter(BasePlatformAdapter):
message_id=msg_id, message_id=msg_id,
raw_message=raw, raw_message=raw,
timestamp=_parse_zulip_timestamp(timestamp), timestamp=_parse_zulip_timestamp(timestamp),
media_urls=media_urls or None,
media_types=media_types or None,
) )
# Store reply metadata so send() can respond to the right thread # Store reply metadata so send() can respond to the right thread