Files
hermes-relay/plugin/tools/android_notifications.py
Bailey DixonandClaude Opus 4.6 1e92138460 feat(phase3-ε): add notification companion service for opt-in notification triage
Phase 3 / Wave 1 / ε. Adds an opt-in helper that lets the user's
Hermes assistant read notifications they've explicitly granted
access to via Android's NotificationListenerService — the same
public API Wear OS, Android Auto, and Tasker have used for over
a decade. Disabled by default; user controls grant/revoke via
Android Settings → Notification access.

Three pieces (all PHASE3-ε marker-blocked in shared files):

* Phone — HermesNotificationCompanion (NotificationListenerService
  subclass) + NotificationModels + NotificationCompanionSettingsScreen
  (About / Status / Test sections, mirrors VoiceSettingsScreen). Cold-
  start buffer up to 50 envelopes, drops on relay-offline (matches
  smartwatch-out-of-range semantics). Skips notifications with no
  human-readable content.

* Server — NotificationsChannel with collections.deque(maxlen=100)
  for LRU-by-time eviction. Wired into RelayServer __init__ + _on_message
  dispatch. In-memory only, lost on restart by design.

* Tool — android_notifications_recent(limit=20) registers via
  tools.registry, hits /notifications/recent over loopback (no auth
  needed for loopback callers, matches /media/register trust model).
  Stdlib urllib.request only.

No new session grant type (reuses existing chat grant trust boundary
per spec). ChannelMultiplexer.sendNotification() wrapper for the
outbound path. python -m py_compile clean on all touched/new files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:28:12 -04:00

169 lines
5.8 KiB
Python

"""
hermes-relay plugin — Android notifications tool.
Registers ``android_notifications_recent`` into the hermes-agent tool
registry. The tool reads the bounded in-memory deque maintained by the
relay's :class:`NotificationsChannel`, populated when the user has
explicitly granted Android's notification-listener permission to the
companion phone app.
Permission model:
* Off by default. The user must explicitly enable Hermes-Relay in
Android Settings → Notification access. This is the same opt-in
Wear OS, Android Auto, and Tasker have used for over a decade.
* The agent only sees what the phone has chosen to share — there is
no remote-trigger path that can re-enable the listener if the user
revokes it.
* The cache is in-memory only on the relay; restarting the relay
drops everything.
Calls the relay over loopback (127.0.0.1) and falls through to a clean
JSON error envelope on any failure so the LLM can render "I don't have
notifications enabled" as natural language instead of crashing.
"""
import json
import os
from typing import Optional
import urllib.error
import urllib.request
def _relay_url() -> str:
"""Base URL of the local relay. Honours ``RELAY_PORT`` if set."""
port = os.getenv("RELAY_PORT", "8767")
return f"http://127.0.0.1:{port}"
def _timeout() -> float:
return float(os.getenv("RELAY_TOOL_TIMEOUT", "5"))
def android_notifications_recent(limit: int = 20) -> str:
"""List recent notifications the user has shared with this assistant.
Returns the most-recent ``limit`` notifications cached on the
relay (capped at 100). Each entry has package_name, title, text,
sub_text, posted_at (epoch ms), and key. Returns a structured
error JSON envelope if the relay is unreachable or the user has
not granted notification access on their phone.
"""
# Clamp the limit defensively — the LLM may try silly values.
if not isinstance(limit, int) or limit < 1:
limit = 20
if limit > 100:
limit = 100
url = f"{_relay_url()}/notifications/recent?limit={limit}"
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=_timeout()) as resp:
if resp.status != 200:
return json.dumps(
{
"status": "error",
"message": f"Relay returned HTTP {resp.status}",
}
)
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
return json.dumps(
{
"status": "error",
"message": f"Relay HTTP {exc.code}: {exc.reason}",
}
)
except (urllib.error.URLError, OSError) as exc:
return json.dumps(
{
"status": "error",
"message": (
"Cannot reach hermes-relay on loopback. Is the relay "
f"running? ({exc})"
),
}
)
try:
data = json.loads(raw)
except json.JSONDecodeError:
return json.dumps(
{"status": "error", "message": "Relay returned non-JSON body"}
)
notifications = data.get("notifications") or []
return json.dumps(
{
"status": "ok",
"count": len(notifications),
"notifications": notifications,
}
)
# ── Tool schema ─────────────────────────────────────────────────────────────
_SCHEMAS = {
"android_notifications_recent": {
"name": "android_notifications_recent",
"description": (
"List recent notifications the user has shared with this "
"assistant. Returns up to `limit` of the most recent "
"notifications the user's phone has forwarded after they "
"opted in via Android's notification-access permission. "
"Use this when the user asks 'what came in while I was "
"away?' or 'summarize my unread messages'. Returns "
"package_name, title, text, sub_text, posted_at (epoch "
"ms), and key for each entry. Empty list means either no "
"recent notifications or the user has not granted "
"notification access yet."
),
"parameters": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": (
"Maximum number of notifications to return "
"(1-100, default 20, newest first)"
),
"default": 20,
},
},
"required": [],
},
},
}
# ── Tool handlers map ───────────────────────────────────────────────────────
_HANDLERS = {
"android_notifications_recent": (
lambda args, **kw: android_notifications_recent(**args)
),
}
# ── Registry registration ──────────────────────────────────────────────────
try:
from tools.registry import registry # type: ignore[import-not-found]
for tool_name, schema in _SCHEMAS.items():
registry.register(
name=tool_name,
toolset="android",
schema=schema,
handler=_HANDLERS[tool_name],
# No bridge connectivity required — this tool reads the
# relay's in-memory cache directly via loopback HTTP.
check_fn=lambda: True,
requires_env=[],
)
except ImportError:
# Running outside hermes-agent context (e.g. tests).
pass