M3: channels/threads + cron delivery + search + outbox sync
Gateway plugin: - ChannelDirectory (SQLite): channels + threads, friendly-name resolution - FTS5 search bridge over state.db (scope all/chat, LIKE fallback) - Outbox (monotonic cursor, 72h retention) for reconnect catch-up - adapter: channel.* handlers, search, sync, cron target parsing - ws_server: M3 frame routing + hello.ack sync cursor App (KMP): - ChannelStore (directory cache) + lane-aware ChatStore (per chat/thread) - GatewayClient.sendFrame; IrisController channel/search/nav actions - ChatScreen: channel drawer, thread toggle, topic switcher, search overlay
This commit is contained in:
1 parent
218c50d688
commit
9b511fdd28
13 files changed
+2035
-259
No files matched your search
+294
-37
@@ -92,6 +92,9 @@ from gateway.config import Platform # noqa: E402
|
||||
from hermes_constants import get_hermes_home # noqa: E402
|
||||
|
||||
from . import protocol # noqa: E402
|
||||
from . import search as search_bridge # noqa: E402
|
||||
from .channels import get_directory # noqa: E402
|
||||
from .outbox import Outbox # noqa: E402
|
||||
from .pairing import ( # noqa: E402
|
||||
DeviceRegistry,
|
||||
generate_token,
|
||||
@@ -471,24 +474,45 @@ def _parse_port(raw: str) -> int:
|
||||
def _parse_target_ref(target_ref: str) -> Optional[tuple]:
|
||||
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
|
||||
|
||||
Recognises the native syntax ``android:<chat>[:<thread>]``. Returns
|
||||
``None`` for anything else so the target proceeds to channel-directory
|
||||
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.
|
||||
"""
|
||||
if not target_ref or not target_ref.startswith("android:"):
|
||||
if not target_ref:
|
||||
return None
|
||||
body = target_ref[len("android:"):]
|
||||
if not body:
|
||||
t = target_ref.strip()
|
||||
if not t:
|
||||
return None
|
||||
if ":" in body:
|
||||
chat_id, thread_id = body.split(":", 1)
|
||||
thread_id = thread_id or None
|
||||
else:
|
||||
chat_id, thread_id = body, None
|
||||
chat_id = chat_id.strip()
|
||||
if not chat_id:
|
||||
return None
|
||||
return (chat_id, thread_id)
|
||||
|
||||
if t.startswith("android:"):
|
||||
body = t[len("android:"):].strip()
|
||||
if not body:
|
||||
return None
|
||||
thread_id: Optional[str] = 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)
|
||||
|
||||
# Bare friendly name -> resolve via the channel directory. A thread resolves
|
||||
# to its session lane (parent_chat_id + thread_id); a channel/default to
|
||||
# its chat_id.
|
||||
try:
|
||||
entry = get_directory().resolve_entry(t)
|
||||
except Exception:
|
||||
entry = None
|
||||
if entry is not None:
|
||||
if entry["kind"] == "thread":
|
||||
return (entry["parent_chat_id"], entry["chat_id"])
|
||||
return (entry["chat_id"], None)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -648,6 +672,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._connected = False
|
||||
# M2: per-chat turn state for outbound frame classification.
|
||||
self._turns: Dict[str, _TurnState] = {}
|
||||
# M3: channel directory (shared singleton) + offline outbox.
|
||||
self._channels = get_directory()
|
||||
self._outbox = Outbox(
|
||||
get_hermes_home() / "android" / "outbox.db",
|
||||
retention_hours=self.outbox_retention_hours,
|
||||
)
|
||||
|
||||
def _turn_state(self, chat_id: str) -> _TurnState:
|
||||
st = self._turns.get(chat_id)
|
||||
@@ -695,6 +725,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
# M3: ensure the default (home) channel exists in the directory so the
|
||||
# app's channel list and cron home delivery have a stable anchor.
|
||||
try:
|
||||
self._channels.ensure_default(self.home_channel, self.home_channel_name)
|
||||
except Exception:
|
||||
logger.warning("android: ensure_default failed", exc_info=True)
|
||||
|
||||
self._connected = True
|
||||
self._mark_connected()
|
||||
logger.info("android: connected; WS server on %s:%s", self.host, self.port)
|
||||
@@ -716,6 +753,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._devices.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._outbox.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._connected = False
|
||||
self._mark_disconnected()
|
||||
logger.info("android: disconnected")
|
||||
@@ -984,10 +1025,16 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
|
||||
delivered = await self._ws_server.broadcast(frame)
|
||||
if delivered == 0:
|
||||
logger.info(
|
||||
"android: no live devices for %s; %s frame not delivered (outbox lands in M3)",
|
||||
chat_id, frame.type,
|
||||
)
|
||||
# M3: no live device -- park the frame in the outbox so a
|
||||
# reconnecting app can `sync` it (M5 adds push to wake the device).
|
||||
try:
|
||||
cursor = self._outbox.append(chat_id, frame.to_json())
|
||||
logger.info(
|
||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||
chat_id, frame.type, cursor,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: outbox append failed", exc_info=True)
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Send a typing indicator (``typing`` frame, on=true)."""
|
||||
@@ -1080,50 +1127,260 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
# ── M3: channel directory management (app -> agent) ───────────────────
|
||||
#
|
||||
# Each request is answered by broadcasting the matching ``channel.*``
|
||||
# event carrying the request ``id``: the requester's pending request
|
||||
# completes on the id, and every other device reconciles its local copy
|
||||
# from the same frame (single broadcast serves as event + response).
|
||||
|
||||
async def on_channel_create(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
payload = frame.payload
|
||||
name = payload.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "channel.create requires a name", id=frame.id),
|
||||
)
|
||||
return
|
||||
kind = payload.get("kind")
|
||||
kind = kind if kind in ("channel", "thread") else "channel"
|
||||
parent_chat_id = payload.get("parent_chat_id")
|
||||
if not isinstance(parent_chat_id, str) or not parent_chat_id.strip():
|
||||
parent_chat_id = None
|
||||
if kind == "thread" and not parent_chat_id:
|
||||
parent_chat_id = frame.chat_id or self.home_channel
|
||||
try:
|
||||
entry = self._channels.create(name=name, kind=kind, parent_chat_id=parent_chat_id)
|
||||
except ValueError as e:
|
||||
await self._ws_server.send_to(
|
||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||
)
|
||||
return
|
||||
resp = protocol.channel_created(entry)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "channel.rename requires chat_id", id=frame.id),
|
||||
)
|
||||
return
|
||||
name = frame.payload.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "channel.rename requires a name", id=frame.id),
|
||||
)
|
||||
return
|
||||
try:
|
||||
entry = self._channels.rename(chat_id, name)
|
||||
except ValueError as e:
|
||||
await self._ws_server.send_to(
|
||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||
)
|
||||
return
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "channel.set_default requires chat_id", id=frame.id),
|
||||
)
|
||||
return
|
||||
entry = self._channels.set_default(chat_id)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||
# new is_default flag) so every device reconciles the default change.
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "channel.delete requires chat_id", id=frame.id),
|
||||
)
|
||||
return
|
||||
entry = self._channels.delete(chat_id)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"cannot delete {chat_id} (unknown or default)", id=frame.id),
|
||||
)
|
||||
return
|
||||
resp = protocol.channel_deleted(chat_id)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_list(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
channels = self._channels.list(include_archived=False)
|
||||
resp = protocol.channel_list(channels)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.send_to(device_id, resp)
|
||||
|
||||
# ── M3: search (app -> agent) ─────────────────────────────────────────
|
||||
|
||||
async def on_search(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
payload = frame.payload
|
||||
query = payload.get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, "search requires a query", id=frame.id)
|
||||
)
|
||||
return
|
||||
scope = payload.get("scope")
|
||||
scope = scope if scope in ("all", "chat") else "all"
|
||||
chat_id = payload.get("chat_id") or frame.chat_id
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
chat_id = None
|
||||
thread_id = payload.get("thread_id") or frame.thread_id
|
||||
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||
thread_id = None
|
||||
limit = payload.get("limit")
|
||||
try:
|
||||
limit = int(limit) if limit is not None else 20
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
db_path = get_hermes_home() / "state.db"
|
||||
hits = search_bridge.search(
|
||||
db_path, query, scope=scope, chat_id=chat_id, thread_id=thread_id, limit=limit
|
||||
)
|
||||
resp = protocol.search_results(query, scope, hits, id=frame.id)
|
||||
await self._ws_server.send_to(device_id, resp)
|
||||
|
||||
# ── M3: sync (reconnect catch-up) ─────────────────────────────────────
|
||||
|
||||
async def on_sync(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
payload = frame.payload
|
||||
cursor = payload.get("cursor")
|
||||
try:
|
||||
cursor = int(cursor) if cursor is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
cursor = 0
|
||||
for e in self._outbox.replay(cursor):
|
||||
raw = e["frame"]
|
||||
replayed = protocol.Frame(
|
||||
type=raw.get("type", ""),
|
||||
payload=raw.get("payload", {}) if isinstance(raw.get("payload"), dict) else {},
|
||||
id=raw.get("id") if isinstance(raw.get("id"), int) else None,
|
||||
chat_id=raw.get("chat_id") if isinstance(raw.get("chat_id"), str) else e.get("chat_id"),
|
||||
thread_id=raw.get("thread_id") if isinstance(raw.get("thread_id"), str) else None,
|
||||
v=raw.get("v") if isinstance(raw.get("v"), int) else protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
await self._ws_server.send_to(device_id, replayed)
|
||||
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
|
||||
await self._ws_server.send_to(device_id, done)
|
||||
|
||||
# ── Chat info ─────────────────────────────────────────────────────────
|
||||
|
||||
def _channel_name(self, chat_id: str) -> str:
|
||||
"""Channel display name. M1: home channel only (directory is M3)."""
|
||||
"""Channel display name (M3: from the channel directory)."""
|
||||
if not chat_id:
|
||||
return "chat"
|
||||
entry = self._channels.get(chat_id)
|
||||
if entry is not None:
|
||||
return entry["name"]
|
||||
if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL):
|
||||
return self.home_channel_name
|
||||
return chat_id or "chat"
|
||||
return chat_id
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return ``{name, type, chat_id}`` for a chat.
|
||||
|
||||
M1: the channel directory is not persisted yet, so report the home
|
||||
channel name for the default chat and the raw id otherwise.
|
||||
"""
|
||||
"""Return ``{name, type, chat_id}`` for a chat (M3: directory-backed)."""
|
||||
entry = self._channels.get(chat_id)
|
||||
kind = entry["kind"] if entry else "channel"
|
||||
return {
|
||||
"name": self._channel_name(chat_id),
|
||||
"type": "channel",
|
||||
"type": "dm" if kind == "default" else "channel",
|
||||
"chat_id": chat_id,
|
||||
}
|
||||
|
||||
# ── hello.ack helpers ─────────────────────────────────────────────────
|
||||
|
||||
def server_caps(self) -> Dict[str, Any]:
|
||||
"""Capability flags advertised in ``hello.ack`` (M2 surface)."""
|
||||
"""Capability flags advertised in ``hello.ack`` (M3 surface)."""
|
||||
return {
|
||||
"streaming": True, # M2: message.start/update/stop
|
||||
"reasoning": True, # M2: reasoning field on message / message.stop
|
||||
"tools": True, # M2: tool.start/progress/end
|
||||
"media": False, # M4
|
||||
"search": False, # M3
|
||||
"search": True, # M3: search frame
|
||||
"push": self.push_backend,
|
||||
"pickers": False, # M2+
|
||||
}
|
||||
|
||||
def channel_list(self) -> List[Dict[str, Any]]:
|
||||
"""Channel directory for ``hello.ack``. M1: home channel only (M3)."""
|
||||
return [
|
||||
{
|
||||
"chat_id": self.home_channel,
|
||||
"name": self.home_channel_name,
|
||||
"kind": "default",
|
||||
"is_default": True,
|
||||
}
|
||||
]
|
||||
"""Channel directory for ``hello.ack`` (M3: full non-archived list)."""
|
||||
return self._channels.list(include_archived=False)
|
||||
|
||||
# ── M3: core channel-directory hook (cron / send_message name resolution)
|
||||
|
||||
async def list_channels(self) -> List[Dict[str, Any]]:
|
||||
"""Expose the directory to the gateway's core channel directory.
|
||||
|
||||
``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>``
|
||||
syntax (see ``_parse_target_ref``), so only channels are listed here.
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for entry in self._channels.list(include_archived=False):
|
||||
if entry["kind"] == "thread":
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": entry["chat_id"],
|
||||
"name": entry["name"],
|
||||
"type": "dm" if entry["kind"] == "default" else "channel",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
# ── M3: thread handoff (gateway create_handoff_thread) ────────────────
|
||||
|
||||
async def create_handoff_thread(
|
||||
self, parent_chat_id: str, name: str
|
||||
) -> Optional[str]:
|
||||
"""Mint a named thread under *parent_chat_id* (gateway handoff path).
|
||||
|
||||
Returns the new ``thread_id`` (``t_<n>``) so the handed-off session is
|
||||
isolated in its own lane, or ``None`` when the parent is unknown.
|
||||
"""
|
||||
parent = self._channels.get(parent_chat_id)
|
||||
if parent is None:
|
||||
# Unknown parent: still mint a thread under it so the handoff has a
|
||||
# lane (the directory row is created lazily on first use).
|
||||
parent_chat_id = parent_chat_id or self.home_channel
|
||||
try:
|
||||
entry = self._channels.create(
|
||||
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: create_handoff_thread failed", exc_info=True)
|
||||
return None
|
||||
await self._ws_server.broadcast(protocol.channel_created(entry))
|
||||
return entry["chat_id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user