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:
ARIA committed 2026-08-19 14:53:48 +02:00
1 parent 218c50d688
commit 9b511fdd28
13 files changed
+2035 -259

No files matched your search

+93 -1
View File
@@ -17,7 +17,7 @@ Milestone M5: notification, fcm.register, read.receipt.
import json
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
PROTOCOL_VERSION = 1
@@ -50,6 +50,24 @@ TYPE_TOOL_END = "tool.end"
# Intermediate assistant beat (M2)
TYPE_COMMENTARY = "commentary"
# Channels / threads (M3)
TYPE_CHANNEL_CREATE = "channel.create"
TYPE_CHANNEL_RENAME = "channel.rename"
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
TYPE_CHANNEL_DELETE = "channel.delete"
TYPE_CHANNEL_CREATED = "channel.created"
TYPE_CHANNEL_RENAMED = "channel.renamed"
TYPE_CHANNEL_DELETED = "channel.deleted"
TYPE_CHANNEL_LIST = "channel.list"
# Search (M3)
TYPE_SEARCH = "search"
TYPE_SEARCH_RESULTS = "search.results"
# Reconnect catch-up (M3 outbox; extended by M5 push)
TYPE_SYNC = "sync"
TYPE_SYNC_DONE = "sync.done"
# ---------------------------------------------------------------------------
# Error codes (``error`` frame payload.code)
# ---------------------------------------------------------------------------
@@ -352,6 +370,80 @@ def commentary(
)
# ---------------------------------------------------------------------------
# Channel directory frames (M3)
# ---------------------------------------------------------------------------
def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
"""Project a directory entry onto the wire shape."""
payload: Dict[str, Any] = {
"chat_id": entry.get("chat_id"),
"name": entry.get("name"),
"kind": entry.get("kind", "channel"),
}
if entry.get("parent_chat_id") is not None:
payload["parent_chat_id"] = entry["parent_chat_id"]
if entry.get("is_default"):
payload["is_default"] = True
if entry.get("archived"):
payload["archived"] = True
return payload
def channel_created(entry: Dict[str, Any]) -> Frame:
"""Broadcast: a channel/thread was created."""
return Frame(type=TYPE_CHANNEL_CREATED, payload=_channel_payload(entry))
def channel_renamed(entry: Dict[str, Any]) -> Frame:
"""Broadcast: a channel/thread was renamed."""
return Frame(type=TYPE_CHANNEL_RENAMED, payload=_channel_payload(entry))
def channel_deleted(chat_id: str) -> Frame:
"""Broadcast: a channel was archived (soft-deleted)."""
return Frame(type=TYPE_CHANNEL_DELETED, payload={"chat_id": chat_id})
def channel_list(channels: List[Dict[str, Any]]) -> Frame:
"""Full directory (response to a ``channel.list`` request)."""
return Frame(
type=TYPE_CHANNEL_LIST,
payload={"channels": [_channel_payload(c) for c in channels]},
)
# ---------------------------------------------------------------------------
# Search frames (M3)
# ---------------------------------------------------------------------------
def search_results(
query: str,
scope: str,
hits: List[Dict[str, Any]],
*,
id: Optional[int] = None,
) -> Frame:
"""Response to a ``search`` request.
Each hit: ``{message_id, chat_id, thread_id, role, snippet, ts}``.
"""
return Frame(
type=TYPE_SEARCH_RESULTS,
id=id,
payload={"query": query, "scope": scope, "hits": hits},
)
# ---------------------------------------------------------------------------
# Sync frames (M3 outbox)
# ---------------------------------------------------------------------------
def sync_done(cursor: int, *, id: Optional[int] = None) -> Frame:
"""Terminal frame of a ``sync`` replay: the new cursor to persist."""
return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor})
def error(code: str, message: str, *, id: Optional[int] = None) -> Frame:
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})