Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
This commit is contained in:
1 parent
27dc7917f2
commit
7a6d922d12
63 files changed
+2073
-630
No files matched your search
+158
-124
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Android Platform Adapter for Hermes Agent (Iris x Hermes).
|
||||
Iris Platform Adapter for Hermes Agent (Iris x Hermes).
|
||||
|
||||
A plugin-based gateway adapter that runs an HTTP server *inside* the
|
||||
``hermes gateway`` process. The native Android / Desktop app connects to it
|
||||
@@ -34,7 +34,7 @@ register the (delivery-validated) file in the media registry and emit
|
||||
|
||||
Milestone M5: push + offline. Frames with no live subscriber are parked in
|
||||
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
|
||||
HTTP v1 primary, ntfy fallback, selected by ``ANDROID_PUSH_BACKEND``).
|
||||
HTTP v1 primary, ntfy fallback, selected by ``IRIS_PUSH_BACKEND``).
|
||||
``notification`` frames render in-app banners and mirror to push (channel
|
||||
events, cron deliveries, approvals, clarifies); high-priority kinds push even
|
||||
when a device is live. ``fcm.register`` rotates push tokens (registry + live
|
||||
@@ -44,19 +44,19 @@ Configuration in config.yaml::
|
||||
|
||||
gateway:
|
||||
platforms:
|
||||
android:
|
||||
iris:
|
||||
enabled: true
|
||||
extra:
|
||||
host: 127.0.0.1
|
||||
port: 8790
|
||||
home_channel: android:default
|
||||
home_channel: default
|
||||
push_backend: fcm
|
||||
outbox_retention_hours: 72
|
||||
max_upload_bytes: 104857600
|
||||
|
||||
Or via environment variables (overrides config.yaml; secrets live in .env):
|
||||
ANDROID_TOKEN, ANDROID_WS_HOST, ANDROID_WS_PORT, ANDROID_HOME_CHANNEL,
|
||||
ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
||||
IRIS_TOKEN, IRIS_WS_HOST, IRIS_WS_PORT, IRIS_HOME_CHANNEL,
|
||||
IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -116,7 +116,10 @@ from gateway.platforms.base import ( # noqa: E402
|
||||
from hermes_constants import get_hermes_home # noqa: E402
|
||||
|
||||
from . import media as media_bridge # noqa: E402
|
||||
from . import protocol # noqa: E402
|
||||
from . import ( # noqa: E402
|
||||
protocol,
|
||||
qr,
|
||||
)
|
||||
from . import purge as purge_bridge # noqa: E402
|
||||
from . import search as search_bridge # noqa: E402
|
||||
from .channels import get_directory # noqa: E402
|
||||
@@ -124,6 +127,7 @@ from .http_server import HttpServer # noqa: E402
|
||||
from .outbox import Outbox # noqa: E402
|
||||
from .pairing import ( # noqa: E402
|
||||
DeviceRegistry,
|
||||
advertise_host,
|
||||
generate_token,
|
||||
pairing_url,
|
||||
qr_payload,
|
||||
@@ -147,7 +151,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
from hermes_cli import commands as hermes_commands
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"android: slash catalog unavailable (hermes_cli.commands import failed)",
|
||||
"iris: slash catalog unavailable (hermes_cli.commands import failed)",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
@@ -175,7 +179,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
except Exception:
|
||||
# Code skew: the private helpers moved. Fall back to the plain
|
||||
# cli_only filter (config-gated commands are dropped, acceptable).
|
||||
logger.warning("android: slash catalog fell back to cli_only filter", exc_info=True)
|
||||
logger.warning("iris: slash catalog fell back to cli_only filter", exc_info=True)
|
||||
entries = [
|
||||
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
|
||||
for cmd in hermes_commands.COMMAND_REGISTRY
|
||||
@@ -187,7 +191,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
except Exception:
|
||||
# Best-effort: a broken plugin-command registry should not break the
|
||||
# built-in catalog, so the failure is intentionally swallowed.
|
||||
logger.debug("android: plugin command enumeration failed", exc_info=True)
|
||||
logger.debug("iris: plugin command enumeration failed", exc_info=True)
|
||||
return entries
|
||||
|
||||
|
||||
@@ -200,7 +204,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
|
||||
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
|
||||
# We accumulate those deltas here and attach the result to the turn's
|
||||
# ``message.stop`` frame. Single-chat for now (android:default), so a
|
||||
# ``message.stop`` frame. Single-chat for now (the default home channel), so a
|
||||
# module-level buffer suffices; it is reset at each turn start.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -261,7 +265,7 @@ def _reset_reasoning() -> None:
|
||||
# (Settings → Tool detail), we capture each completed tool call via the
|
||||
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
|
||||
#
|
||||
# Global FIFO (like the reasoning buffer): a personal android gateway serves
|
||||
# Global FIFO (like the reasoning buffer): a personal iris gateway serves
|
||||
# one active turn at a time, and records are matched to the open tool by name
|
||||
# in completion order. Bounded so a runaway turn can't grow it without limit.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,8 +360,8 @@ def _tool_emoji(tool_name: str) -> str | None:
|
||||
# * turn start — the first API call of the turn (latency baseline)
|
||||
#
|
||||
# Global buffer (same pattern as the reasoning/tool buffers): a personal
|
||||
# android gateway serves one active turn at a time. The hook fires for every
|
||||
# platform, so we only record when the turn's platform is android.
|
||||
# iris gateway serves one active turn at a time. The hook fires for every
|
||||
# platform, so we only record when the turn's platform is iris.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_runtime_meta: dict[str, Any] = {}
|
||||
@@ -374,7 +378,7 @@ _CTX_RESOLVE_TIMEOUT_S = 3.0
|
||||
def _on_post_api_request(**kwargs: Any) -> None:
|
||||
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
|
||||
platform = kwargs.get("platform")
|
||||
if platform and platform != "android":
|
||||
if platform and platform != "iris":
|
||||
return
|
||||
model = kwargs.get("model") or ""
|
||||
usage = kwargs.get("usage") or {}
|
||||
@@ -416,7 +420,7 @@ def _resolve_context_length(model: str) -> int | None:
|
||||
_context_length_cache[model] = int(ctx)
|
||||
return int(ctx)
|
||||
except Exception:
|
||||
logger.debug("android: context-length resolution failed for %s", model, exc_info=True)
|
||||
logger.debug("iris: context-length resolution failed for %s", model, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -472,7 +476,7 @@ async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8790
|
||||
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
|
||||
DEFAULT_HOME_CHANNEL = "android:default"
|
||||
DEFAULT_HOME_CHANNEL = "default"
|
||||
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||
DEFAULT_PUSH_BACKEND = "fcm"
|
||||
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
||||
@@ -825,13 +829,13 @@ def check_requirements() -> bool:
|
||||
dashboard readiness). Never installs. The HTTP transport is stdlib-only,
|
||||
so there is no extra dependency to probe.
|
||||
"""
|
||||
return bool(_get_scoped_secret("ANDROID_TOKEN"))
|
||||
return bool(_get_scoped_secret("IRIS_TOKEN"))
|
||||
|
||||
|
||||
def validate_config(config) -> bool:
|
||||
"""Given a PlatformConfig, is the platform properly configured?"""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
||||
token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
|
||||
return bool(token)
|
||||
|
||||
|
||||
@@ -858,7 +862,7 @@ def _env_enablement() -> dict | None:
|
||||
core hook -- it becomes a proper ``HomeChannel`` dataclass on the
|
||||
``PlatformConfig`` rather than being merged into ``extra``.
|
||||
"""
|
||||
token = _get_scoped_secret("ANDROID_TOKEN", "")
|
||||
token = _get_scoped_secret("IRIS_TOKEN", "")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
@@ -867,20 +871,20 @@ def _env_enablement() -> dict | None:
|
||||
# clobber user YAML. Unset keys fall through to config.yaml / adapter
|
||||
# defaults.
|
||||
seed: dict[str, Any] = {}
|
||||
host = os.getenv("ANDROID_WS_HOST", "").strip()
|
||||
host = os.getenv("IRIS_WS_HOST", "").strip()
|
||||
if host:
|
||||
seed["host"] = host
|
||||
http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip()
|
||||
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
|
||||
if http_port_raw:
|
||||
seed["http_port"] = _parse_port(http_port_raw)
|
||||
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
||||
push = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower()
|
||||
if push:
|
||||
seed["push_backend"] = push
|
||||
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip()
|
||||
home = os.getenv("IRIS_HOME_CHANNEL", "").strip()
|
||||
if home:
|
||||
seed["home_channel"] = {
|
||||
"chat_id": home,
|
||||
"name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
|
||||
"name": os.getenv("IRIS_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
|
||||
}
|
||||
return seed
|
||||
|
||||
@@ -893,21 +897,22 @@ def _parse_port(raw: str) -> int:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Target parsing: "android:<chat>[:<thread>]"
|
||||
# Target parsing: "<chat_id>[:<thread>]" (platform prefix stripped by core)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_target_ref(target_ref: str) -> tuple | None:
|
||||
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
|
||||
|
||||
Recognises the native syntax ``android:<chat>[:<thread>]`` where the
|
||||
chat_id itself carries the ``android:`` prefix (e.g. ``android:chan_7``)
|
||||
and an optional thread is a trailing ``:t_<n>``. A bare friendly name
|
||||
(e.g. ``Cron Reports``) is resolved against the channel directory so cron
|
||||
/ ``send_message`` can target a channel by name immediately, without
|
||||
waiting for the core directory's refresh timer. Returns ``None`` for
|
||||
anything unrecognised so the target proceeds to the core channel-directory
|
||||
resolution.
|
||||
The core strips the platform prefix before calling us, so the native
|
||||
syntax is simply ``<chat_id>[:<thread>]`` (e.g. ``chan_7`` or
|
||||
``chan_7:t_31``); the home channel is ``default``. Chat ids are direct
|
||||
(no embedded platform prefix), so a cron delivery reads
|
||||
``iris:chan_7`` end to end. A bare friendly name (e.g. ``Cron Reports``)
|
||||
is resolved against the channel directory so cron / ``send_message`` can
|
||||
target a channel by name immediately, without waiting for the core
|
||||
directory's refresh timer. Returns ``None`` for anything unrecognised so
|
||||
the target proceeds to the core channel-directory resolution.
|
||||
"""
|
||||
if not target_ref:
|
||||
return None
|
||||
@@ -915,17 +920,26 @@ def _parse_target_ref(target_ref: str) -> tuple | None:
|
||||
if not t:
|
||||
return None
|
||||
|
||||
if t.startswith("android:"):
|
||||
body = t[len("android:") :].strip()
|
||||
if not body:
|
||||
return None
|
||||
thread_id: str | None = None
|
||||
if ":" in body:
|
||||
head, tail = body.rsplit(":", 1)
|
||||
if tail and tail.startswith("t_"):
|
||||
thread_id = tail
|
||||
body = head
|
||||
return (f"android:{body}", thread_id)
|
||||
thread_id: str | None = None
|
||||
if ":" in t:
|
||||
head, tail = t.rsplit(":", 1)
|
||||
if head and tail.startswith("t_"):
|
||||
thread_id = tail
|
||||
t = head
|
||||
else:
|
||||
# Not a <chat>:<thread> pair -- treat the whole string as a name.
|
||||
t = target_ref.strip()
|
||||
if not t:
|
||||
return None
|
||||
|
||||
# Native chat id (default / chan_<n>) or any id known to the directory
|
||||
# (covers custom IRIS_HOME_CHANNEL values).
|
||||
try:
|
||||
known = get_directory().get(t) is not None
|
||||
except Exception:
|
||||
known = False
|
||||
if t == "default" or re.fullmatch(r"chan_\d+", t) or known:
|
||||
return (t, thread_id)
|
||||
|
||||
# Bare friendly name -> resolve via the channel directory. A thread resolves
|
||||
# to its session lane (parent_chat_id + thread_id); a channel/default to
|
||||
@@ -966,7 +980,7 @@ async def _standalone_send(
|
||||
"""
|
||||
return {
|
||||
"error": (
|
||||
"android standalone send: the running gateway is required to serve "
|
||||
"iris standalone send: the running gateway is required to serve "
|
||||
"the outbox (standalone delivery is best-effort only)"
|
||||
)
|
||||
}
|
||||
@@ -978,7 +992,7 @@ async def _standalone_send(
|
||||
|
||||
|
||||
def _ensure_verbose_tool_progress() -> None:
|
||||
"""Ensure the android platform renders tool progress in ``verbose`` mode.
|
||||
"""Ensure the iris platform renders tool progress in ``verbose`` mode.
|
||||
|
||||
Verbose mode makes the gateway's tool-progress line carry the FULL
|
||||
argument JSON (not just a ~40-char preview), which the adapter parses
|
||||
@@ -988,7 +1002,7 @@ def _ensure_verbose_tool_progress() -> None:
|
||||
it).
|
||||
|
||||
Best-effort and idempotent: writes
|
||||
``display.platforms.android.tool_progress: verbose`` to config.yaml only
|
||||
``display.platforms.iris.tool_progress: verbose`` to config.yaml only
|
||||
when it isn't already set. The gateway's config cache is mtime-keyed, so
|
||||
the write takes effect on the next turn without a restart. Never raises.
|
||||
"""
|
||||
@@ -998,19 +1012,19 @@ def _ensure_verbose_tool_progress() -> None:
|
||||
cfg = load_config_readonly() or {}
|
||||
display = cfg.get("display") or {}
|
||||
platforms = display.get("platforms") or {}
|
||||
android = platforms.get("android") or {}
|
||||
if android.get("tool_progress") == "verbose":
|
||||
iris_cfg = platforms.get("iris") or {}
|
||||
if iris_cfg.get("tool_progress") == "verbose":
|
||||
return # already set
|
||||
from utils import atomic_roundtrip_yaml_update
|
||||
|
||||
atomic_roundtrip_yaml_update(
|
||||
get_hermes_home() / "config.yaml",
|
||||
"display.platforms.android.tool_progress",
|
||||
"display.platforms.iris.tool_progress",
|
||||
"verbose",
|
||||
)
|
||||
logger.info("android: set display.platforms.android.tool_progress=verbose")
|
||||
logger.info("iris: set display.platforms.iris.tool_progress=verbose")
|
||||
except Exception:
|
||||
logger.debug("android: could not ensure verbose tool_progress", exc_info=True)
|
||||
logger.debug("iris: could not ensure verbose tool_progress", exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1033,57 +1047,77 @@ def interactive_setup() -> None:
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
except Exception:
|
||||
print("android: setup helpers unavailable; set ANDROID_TOKEN in ~/.hermes/.env")
|
||||
print("iris: setup helpers unavailable; set IRIS_TOKEN in ~/.hermes/.env")
|
||||
return
|
||||
|
||||
print_info("📱 Android / Desktop (Iris x Hermes)")
|
||||
token = get_env_value("ANDROID_TOKEN") or ""
|
||||
token = get_env_value("IRIS_TOKEN") or ""
|
||||
if not token:
|
||||
generated = generate_token()
|
||||
save_env_value("ANDROID_TOKEN", generated)
|
||||
save_env_value("IRIS_TOKEN", generated)
|
||||
print_success(f"Generated pairing token: {generated}")
|
||||
print_warning("Keep this secret -- the app presents it on connect.")
|
||||
else:
|
||||
print_info("Existing ANDROID_TOKEN found (not shown).")
|
||||
print_info("Existing IRIS_TOKEN found (not shown).")
|
||||
|
||||
host = prompt("Bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
|
||||
save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST)
|
||||
host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
|
||||
save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST)
|
||||
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
|
||||
# HTTP default must be applied explicitly (docs/19: 8791).
|
||||
http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip()
|
||||
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
|
||||
port = prompt(
|
||||
"HTTP port",
|
||||
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
|
||||
)
|
||||
save_env_value("ANDROID_HTTP_PORT", str(_parse_port(port)))
|
||||
save_env_value("IRIS_HTTP_PORT", str(_parse_port(port)))
|
||||
backend = prompt(
|
||||
"Push backend (fcm/ntfy)",
|
||||
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
||||
default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
||||
)
|
||||
save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
|
||||
save_env_value("IRIS_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
|
||||
|
||||
# Pairing payload for the app's Connect screen (manual entry; the app has
|
||||
# no QR scanner).
|
||||
url = pairing_url(host or DEFAULT_HOST, _parse_port(port))
|
||||
# Pairing payload for the app's Connect screen (manual entry + QR scan).
|
||||
# Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is
|
||||
# replaced by the default-route LAN IP so the QR points somewhere a phone
|
||||
# can actually reach (the user can still override the Server URL in-app).
|
||||
advertised = advertise_host(host or DEFAULT_HOST)
|
||||
url = pairing_url(advertised, _parse_port(port))
|
||||
pairing = qr_payload(advertised, _parse_port(port), token)
|
||||
print_info("Pair your device (enter this on the app's Connect screen):")
|
||||
print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}")
|
||||
print_info(f"Pairing URL: {pairing}")
|
||||
print_info(f"Server URL: {url}")
|
||||
if advertised != (host or DEFAULT_HOST):
|
||||
print_info(
|
||||
f"QR points to {advertised} (your default LAN address). If your "
|
||||
"phone is on a different network, change the Server URL in the app."
|
||||
)
|
||||
|
||||
# Scannable QR (docs/20): the same payload as a terminal QR. The URL text
|
||||
# lines stay — the QR is a convenience, not a replacement (non-UTF-8
|
||||
# terminals still work, and the text is copy-pasteable). render_qr returns
|
||||
# '' (not an exception) when the payload is too long to encode.
|
||||
qr_block = qr.render_qr(pairing)
|
||||
if qr_block:
|
||||
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
|
||||
print(qr_block)
|
||||
else:
|
||||
print_warning("QR too large to render; use the pairing URL above.")
|
||||
|
||||
# Always render tool progress verbosely so the app receives the full tool
|
||||
# call args (it decides how much to show via Settings → Tool detail).
|
||||
_ensure_verbose_tool_progress()
|
||||
|
||||
print_success("Android configuration saved to ~/.hermes/.env")
|
||||
print_success("Iris configuration saved to ~/.hermes/.env")
|
||||
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Android Adapter
|
||||
# Iris Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AndroidAdapter(BasePlatformAdapter):
|
||||
"""HTTP-backed adapter for the native Iris Android / Desktop app.
|
||||
class IrisAdapter(BasePlatformAdapter):
|
||||
"""HTTP-backed adapter for the native Iris app (Android / Desktop).
|
||||
|
||||
The HTTP server (``http_server.HttpServer``) authenticates devices with
|
||||
the pairing token, the device registry tracks live subscribers, ``send()``
|
||||
@@ -1100,7 +1134,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
MAX_MESSAGE_LENGTH = 1_000_000
|
||||
|
||||
def __init__(self, config, **kwargs):
|
||||
platform = Platform("android")
|
||||
platform = Platform("iris")
|
||||
super().__init__(config=config, platform=platform)
|
||||
|
||||
# Ensure verbose tool progress (full args on the progress line) so the
|
||||
@@ -1111,13 +1145,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
|
||||
# Connection settings (env vars override config.yaml). The bind host
|
||||
# is shared with the (legacy) WS-era env var name for compatibility.
|
||||
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
||||
self.host = os.getenv("IRIS_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
||||
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
|
||||
self.http_port = _parse_port(
|
||||
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
||||
os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
||||
)
|
||||
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
||||
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get(
|
||||
self.token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
|
||||
self.push_backend = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower() or extra.get(
|
||||
"push_backend", DEFAULT_PUSH_BACKEND
|
||||
)
|
||||
self.outbox_retention_hours = int(
|
||||
@@ -1146,18 +1180,18 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
|
||||
|
||||
# TLS (optional)
|
||||
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "")
|
||||
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
|
||||
self.http_cert = _get_scoped_secret("IRIS_HTTP_CERT") or extra.get("http_cert", "")
|
||||
self.http_key = _get_scoped_secret("IRIS_HTTP_KEY") or extra.get("http_key", "")
|
||||
|
||||
# Auth
|
||||
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
|
||||
allowed = os.getenv("IRIS_ALLOWED_USERS", "").strip()
|
||||
self.allowed_users: list[str] = (
|
||||
[u.strip() for u in allowed.split(",") if u.strip()] if allowed else []
|
||||
)
|
||||
self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS"))
|
||||
self.allow_all = _truthy(os.getenv("IRIS_ALLOW_ALL_USERS"))
|
||||
|
||||
# Runtime state
|
||||
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
|
||||
self._devices = DeviceRegistry(get_hermes_home() / "iris" / "devices.db")
|
||||
# docs/19: HTTP transport (the only device-facing transport).
|
||||
self._http_server = HttpServer(self, self._devices)
|
||||
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
|
||||
@@ -1171,7 +1205,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# M3: channel directory (shared singleton) + offline outbox.
|
||||
self._channels = get_directory()
|
||||
self._outbox = Outbox(
|
||||
get_hermes_home() / "android" / "outbox.db",
|
||||
get_hermes_home() / "iris" / "outbox.db",
|
||||
retention_hours=self.outbox_retention_hours,
|
||||
)
|
||||
# M4: media registry (inbound upload refs + outbound offers) and the
|
||||
@@ -1182,8 +1216,8 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# the outbox-prune banner.
|
||||
self._push: PushBackend = build_push_backend(
|
||||
self.push_backend,
|
||||
fcm_service_account=_get_scoped_secret("ANDROID_FCM_SERVICE_ACCOUNT"),
|
||||
fcm_server_key=_get_scoped_secret("ANDROID_FCM_SERVER_KEY"),
|
||||
fcm_service_account=_get_scoped_secret("IRIS_FCM_SERVICE_ACCOUNT"),
|
||||
fcm_server_key=_get_scoped_secret("IRIS_FCM_SERVER_KEY"),
|
||||
ntfy_topic=_get_scoped_secret("NTFY_TOPIC"),
|
||||
ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None,
|
||||
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
|
||||
@@ -1202,17 +1236,17 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Android"
|
||||
return "Iris"
|
||||
|
||||
# ── Connection lifecycle ──────────────────────────────────────────────
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Bring the platform up: bind the HTTP server on host:http_port."""
|
||||
if not self.token:
|
||||
logger.error("android: ANDROID_TOKEN must be set")
|
||||
logger.error("iris: IRIS_TOKEN must be set")
|
||||
self._set_fatal_error(
|
||||
"config_missing",
|
||||
"ANDROID_TOKEN must be set",
|
||||
"IRIS_TOKEN must be set",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
@@ -1222,7 +1256,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# start() never raises; it disables the leg and logs on failure.
|
||||
await self._http_server.start()
|
||||
if not self._http_server.enabled:
|
||||
logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port)
|
||||
logger.error("iris: HTTP server failed to bind %s:%s", self.host, self.http_port)
|
||||
self._set_fatal_error(
|
||||
"bind_failed",
|
||||
f"HTTP port {self.http_port} unavailable",
|
||||
@@ -1242,21 +1276,21 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
self._channels.ensure_default(self.home_channel, self.home_channel_name)
|
||||
except Exception:
|
||||
logger.warning("android: ensure_default failed", exc_info=True)
|
||||
logger.warning("iris: ensure_default failed", exc_info=True)
|
||||
|
||||
# M5: push backend status (degrade gracefully when unconfigured).
|
||||
if not self._push.configured():
|
||||
logger.warning(
|
||||
"android: push backend %r not configured (no credentials) -- "
|
||||
"iris: push backend %r not configured (no credentials) -- "
|
||||
"offline devices will not be woken; outbox + sync still apply",
|
||||
self.push_backend,
|
||||
)
|
||||
else:
|
||||
logger.info("android: push backend: %s", self._push.name)
|
||||
logger.info("iris: push backend: %s", self._push.name)
|
||||
|
||||
self._connected = True
|
||||
self._mark_connected()
|
||||
logger.info("android: connected; HTTP server on %s:%s", self.host, self.http_port)
|
||||
logger.info("iris: connected; HTTP server on %s:%s", self.host, self.http_port)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
@@ -1271,7 +1305,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
await self._http_server.stop()
|
||||
except Exception:
|
||||
logger.warning("android: HTTP server stop failed", exc_info=True)
|
||||
logger.warning("iris: HTTP server stop failed", exc_info=True)
|
||||
# Best-effort shutdown: a close failure on an already-closed store is
|
||||
# not actionable at disconnect time.
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -1280,7 +1314,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._outbox.close()
|
||||
self._connected = False
|
||||
self._mark_disconnected()
|
||||
logger.info("android: disconnected")
|
||||
logger.info("iris: disconnected")
|
||||
|
||||
# ── Outbound (agent -> app) ───────────────────────────────────────────
|
||||
|
||||
@@ -1706,7 +1740,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
cursor = self._outbox.append(chat_id, frame.to_json())
|
||||
except Exception:
|
||||
logger.warning("android: outbox append failed", exc_info=True)
|
||||
logger.warning("iris: outbox append failed", exc_info=True)
|
||||
return
|
||||
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
|
||||
# — count it in the delivery total or every message would push AND
|
||||
@@ -1714,7 +1748,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
delivered = await self._http_server.fanout(frame, cursor)
|
||||
if delivered == 0:
|
||||
logger.info(
|
||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||
"iris: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||
chat_id,
|
||||
frame.type,
|
||||
cursor,
|
||||
@@ -1786,7 +1820,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
now = time.time()
|
||||
if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S:
|
||||
logger.info(
|
||||
"android: push coalesced for %s (%s frame within %.0fs of last push)",
|
||||
"iris: push coalesced for %s (%s frame within %.0fs of last push)",
|
||||
chat_id,
|
||||
frame.type,
|
||||
_PUSH_COALESCE_S,
|
||||
@@ -1823,7 +1857,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
priority=priority,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: push via %s failed", backend.name, exc_info=True)
|
||||
logger.warning("iris: push via %s failed", backend.name, exc_info=True)
|
||||
continue
|
||||
if ok:
|
||||
# M5: remember that this cursor reached the device via push,
|
||||
@@ -1832,11 +1866,11 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._devices.update_push_cursor(device_id, cursor)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"android: push cursor update failed for %s", device_id, exc_info=True
|
||||
"iris: push cursor update failed for %s", device_id, exc_info=True
|
||||
)
|
||||
self._last_push_at[chat_id] = time.time()
|
||||
logger.info(
|
||||
"android: push via %s -> %s (%s, chat=%s)",
|
||||
"iris: push via %s -> %s (%s, chat=%s)",
|
||||
backend.name,
|
||||
device_id,
|
||||
frame.type,
|
||||
@@ -1897,13 +1931,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
) -> SendResult:
|
||||
safe = validate_media_delivery_path(path)
|
||||
if safe is None:
|
||||
logger.warning("android: media path failed delivery validation: %s", path)
|
||||
return SendResult(success=False, error="android: media path not deliverable")
|
||||
logger.warning("iris: media path failed delivery validation: %s", path)
|
||||
return SendResult(success=False, error="iris: media path not deliverable")
|
||||
try:
|
||||
size = os.path.getsize(safe)
|
||||
except OSError as e:
|
||||
logger.warning("android: media file unreadable %s: %s", safe, e)
|
||||
return SendResult(success=False, error="android: media file unreadable")
|
||||
logger.warning("iris: media file unreadable %s: %s", safe, e)
|
||||
return SendResult(success=False, error="iris: media file unreadable")
|
||||
entry = self._media.register_outbound(
|
||||
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
|
||||
)
|
||||
@@ -2216,7 +2250,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
logger.debug("Thread title rename broadcast failed", exc_info=True)
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="android-thread-title").start()
|
||||
threading.Thread(target=_work, daemon=True, name="iris-thread-title").start()
|
||||
|
||||
# ── M3: channel directory management (app -> agent) ───────────────────
|
||||
#
|
||||
@@ -2455,7 +2489,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
|
||||
)
|
||||
logger.info(
|
||||
"android: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||
"iris: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||
chat_id,
|
||||
entry.get("kind"),
|
||||
removed_frames,
|
||||
@@ -2565,7 +2599,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
payload = frame.payload
|
||||
chat_id = frame.chat_id or payload.get("chat_id")
|
||||
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
|
||||
logger.info("iris: history request from %s chat_id=%r", device_id, chat_id)
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
@@ -2658,7 +2692,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
info.get("ts"),
|
||||
)
|
||||
logger.info(
|
||||
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
||||
"iris: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
||||
device_id,
|
||||
chat_id,
|
||||
thread_id,
|
||||
@@ -2687,9 +2721,9 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic)
|
||||
except Exception:
|
||||
logger.warning("android: fcm.register update failed", exc_info=True)
|
||||
logger.warning("iris: fcm.register update failed", exc_info=True)
|
||||
return
|
||||
logger.info("android: push tokens updated for %s", device_id)
|
||||
logger.info("iris: push tokens updated for %s", device_id)
|
||||
|
||||
# ── M5: approval / clarify banners ────────────────────────────────────
|
||||
|
||||
@@ -2854,7 +2888,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
``gateway/channel_directory.build_channel_directory`` calls this to
|
||||
populate ``channel_directory.json``, which ``resolve_channel_name``
|
||||
reads for friendly-name -> chat_id resolution (cron + send_message).
|
||||
Threads are addressed via the explicit ``android:<chat>:<thread>``
|
||||
Threads are addressed via the explicit ``iris:<chat>:<thread>``
|
||||
syntax (see ``_parse_target_ref``), so only channels are listed here.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
@@ -2888,7 +2922,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: create_handoff_thread failed", exc_info=True)
|
||||
logger.warning("iris: create_handoff_thread failed", exc_info=True)
|
||||
return None
|
||||
await self._broadcast_both(protocol.channel_created(entry))
|
||||
return entry["chat_id"]
|
||||
@@ -2907,14 +2941,14 @@ def register(ctx):
|
||||
try:
|
||||
ctx.register_hook("on_stream_delta", _on_stream_delta)
|
||||
except Exception:
|
||||
logger.debug("android: on_stream_delta hook registration failed", exc_info=True)
|
||||
logger.debug("iris: on_stream_delta hook registration failed", exc_info=True)
|
||||
# M2: capture each completed tool call's result + timing so the tool.end
|
||||
# frame can carry the output (the gateway never streams tool output to
|
||||
# platforms). The app shows it on demand (Settings → Tool detail).
|
||||
try:
|
||||
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
||||
except Exception:
|
||||
logger.debug("android: post_tool_call hook registration failed", exc_info=True)
|
||||
logger.debug("iris: post_tool_call hook registration failed", exc_info=True)
|
||||
# Runtime-metadata footer: capture the turn's model + prompt tokens (per
|
||||
# provider call) so the final message can carry a structured ``runtime``
|
||||
# object. The app decides whether/what to show (Settings → Runtime
|
||||
@@ -2923,30 +2957,30 @@ def register(ctx):
|
||||
try:
|
||||
ctx.register_hook("post_api_request", _on_post_api_request)
|
||||
except Exception:
|
||||
logger.debug("android: post_api_request hook registration failed", exc_info=True)
|
||||
logger.debug("iris: post_api_request hook registration failed", exc_info=True)
|
||||
ctx.register_platform(
|
||||
name="android",
|
||||
label="Android",
|
||||
adapter_factory=AndroidAdapter,
|
||||
name="iris",
|
||||
label="Iris",
|
||||
adapter_factory=IrisAdapter,
|
||||
check_fn=check_requirements,
|
||||
validate_config=validate_config,
|
||||
is_connected=is_connected,
|
||||
required_env=["ANDROID_TOKEN"],
|
||||
required_env=["IRIS_TOKEN"],
|
||||
install_hint="No extra packages needed (httpx is a core dep)",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
||||
# host/port/push_backend + home_channel so env-only setups show up in
|
||||
# gateway status without instantiating the adapter.
|
||||
env_enablement_fn=_env_enablement,
|
||||
# Cron home-channel delivery support (deliver=android:<chat>[:<thread>]).
|
||||
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
||||
# Cron home-channel delivery support (deliver=iris:<chat_id>[:<thread>]).
|
||||
cron_deliver_env_var="IRIS_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery (best-effort; outbox is gateway-served).
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Native target syntax: "android:<chat>[:<thread>]".
|
||||
# Native target syntax: "iris:<chat_id>[:<thread>]" (chat ids are direct, e.g. iris:chan_7).
|
||||
parse_target_ref_fn=_parse_target_ref,
|
||||
# Auth env vars for _is_user_authorized() integration.
|
||||
allowed_users_env="ANDROID_ALLOWED_USERS",
|
||||
allow_all_env="ANDROID_ALLOW_ALL_USERS",
|
||||
allowed_users_env="IRIS_ALLOWED_USERS",
|
||||
allow_all_env="IRIS_ALLOW_ALL_USERS",
|
||||
# WS has no message-size limit.
|
||||
max_message_length=0,
|
||||
# Display.
|
||||
|
||||
Reference in new issue
Block a user