Files
iris_x_hermes/docs/06-channels-cron-search.md
ARIA a61b47a947
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m59s
docs: replace stale 'android' name mentions with 'iris'
The plugin is named 'iris' (IrisAdapter, IRIS_HOME_CHANNEL, label Iris),
but several docs still referred to it as the android platform/plugin and
to the product as 'the Android app'. Rename name-mentions to iris/IRIS
and product-mentions to 'Iris app'; keep legitimate OS references
(androidApp, Android SDK, Android 10, androidx, test_android.py, ...).

Also includes pi-lens markdown-lint autofixes (table spacing, trailing
newlines) in the touched files.
2026-08-24 21:44:02 +02:00

154 lines
8.3 KiB
Markdown

# 06 — Channels, Threads, Cron Delivery, Search
## 6.1 Concept model
The hermes gateway already models conversations as `SessionSource` with
`chat_id` + `thread_id` + `chat_topic` (`gateway/platforms/base.py:7047`
`build_source`). We map app concepts onto these existing primitives — **no new
gateway identity concepts**.
| App concept | hermes primitive | Example |
| --- | --- | --- |
| Default chat | home channel `chat_id` | `default` |
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=default, thread_id=t_12` |
| A user-created channel | a new `chat_id` | `chan_7` |
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=chan_7, thread_id=t_31` |
- **`chat_id`** = the conversation lane (a channel or the default chat).
- **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like).
- The **channel directory** (plugin SQLite, `channels.db`) stores
`{chat_id, name, kind: default|channel|thread, parent_chat_id, is_default,
created}` and is the source of truth for the app's channel list.
## 6.2 Default chat
- On first connect, the plugin ensures a **default channel** exists:
`chat_id = IRIS_HOME_CHANNEL` (default `default`), `kind=default`,
`is_default=true`, name "Default".
- It is also the **cron home channel** (`cron_deliver_env_var=
IRIS_HOME_CHANNEL`), so `deliver=iris` (bare) routes here.
- The app opens the default chat on launch.
## 6.3 Threads (toggle for overview)
- **Requirement:** a default chat where the user can **activate threads** for a
better overview, or not.
- **Thread toggle** (per default chat, in the chat header menu):
- **Threads OFF** — flat conversation; all messages use `thread_id=null`.
- **Threads ON** — the app groups the conversation into topic-like lanes.
Each new "topic" mints a `thread_id` (via `channel.create {kind:thread,
parent_chat_id:default}` or an implicit thread). The UI shows a
topic switcher (like Telegram topics) above the message list.
- Threads are **app-organized** but **gateway-real**: each `thread_id` is a
distinct hermes session lane, so context is isolated per thread and cron can
target a specific thread.
- The gateway's `create_handoff_thread` is used where hermes wants to open a
named thread (e.g. continuable cron).
- **Threading is only active on the default channel.** The topic switcher,
the "new topic" affordance (Ctrl+T), and auto-threading all apply to the
default channel only; user channels stay flat (the gateway also ignores
`auto_thread` for non-default channels).
### 6.3.1 Auto-threading (Telegram topic-mode workflow)
With **Threads ON**, a message sent in a channel's flat lane (no active
thread) gets its **own fresh thread**, the way Telegram topic mode mints a
topic per new conversation — and the **AI names it** instead of the user:
1. The app sends `message.send {…, auto_thread:true}` (only from a flat lane,
with non-empty text, never for slash commands).
2. The gateway mints a thread (`channel.create`-equivalent, `kind:thread`)
under the channel, **named instantly** from the user's opening message via
hermes' session-title derivation (`agent/title_generator.derive_title` —
a deterministic slice of the user's own words, no model call), and
broadcasts `channel.created {auto:true}`.
3. The user echo, the agent turn, and all streaming frames carry the new
`thread_id` — the whole conversation lives in the thread.
4. In the background, the gateway upgrades the name with the model's title
(`agent/title_generator.generate_title`, the `title_generation` auxiliary
task) and broadcasts `channel.renamed`. This is hermes' two-stage session
titling (derived < llm < user) applied to the thread name; failures leave
the derived name in place.
App side: on `channel.created {auto:true}` under the flat lane it is viewing,
the app **jumps into the new thread** and relocates the optimistic pending
bubble from the flat lane into it (the echo arrives in the thread lane).
Follow-ups sent inside the thread stay there; the next flat-lane message
starts another thread. Media-only sends and slash commands stay in the flat
lane (nothing to title / session-scoped, not conversation starters).
## 6.4 User-created channels (for cron delegation)
- **Requirement:** the user creates new channels so **cron job outputs can be
delegated to them** instead of the default chat.
- **`channel.create {name, kind:"channel"}`** → plugin mints
`chat_id = chan_<n>`, stores in directory, broadcasts
`channel.created` to all devices. The new channel appears in the channel list.
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
directory (rename broadcasts `channel.renamed`; delete is a **hard delete**
-- the channel/thread row is removed and the lane's history is wiped from
the outbox and the hermes session store, so nothing is recoverable and no
search trace survives).
- **Automation channels:** a channel can be marked *automation*
(`channel.set_automation {on}`, long-press / right-click menu; the default
channel cannot be marked). Automation channels are **read-only for the
user**: they exist only to receive gateway-originated output (cron job
output, webhook output). The app replaces the composer with a read-only
notice, and the gateway rejects `message.send` into them
(`error unsupported`). The flag is server-authoritative (synced to all
devices via the `channel.renamed` full-entry response) and shown as a gear
badge in the channel list.
- **App affordance for threads:** long-press (touch) / right-click (desktop) a
topic chip → "Rename" (`channel.rename`) or "Delete" (`channel.delete`).
Deleting the open thread falls the app back to the channel's flat lane.
- **Cron targeting** (the key payoff): because the plugin registers
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
`send_message` tool can target any channel/thread:
- `deliver="iris"` → home (default) channel.
- `deliver="iris:chan_7"` → that channel.
- `deliver="iris:chan_7:t_31"` → that channel's thread.
- In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron
Reports* channel"; the gateway resolves the name via the channel directory.
- **In-app affordance:** each channel's menu has "Set as cron target" / shows a
badge when a cron job points at it, and the channel name is offered in the
`/cron` creation flow.
## 6.5 Cron delivery mechanics (how it works under the hood)
- Cron resolves delivery targets in `cron/scheduler.py:2148`
(`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it
calls `tools.send_message_tool.resolve_send_target`, which uses our
`parse_target_ref_fn` to parse `iris:<chat>[:<thread>]`.
- Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway
running) → our WS `message` frame (or outbox+push if the app is offline).
- Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes;
the app can style cron messages distinctly (e.g. a small "⏰ <job name>"
chip) using the `role:"cron"` / header.
- **Mirror option:** hermes `cron.mirror_delivery` (default off) can also mirror
a cron delivery into the origin session; we leave it off to keep channels
clean.
## 6.6 Search
- **Requirement:** search with settings "search everywhere" / "search in this
chat/channel".
- **Backend:** hermes's session store is **SQLite + FTS5**
(`hermes_state.py`, `hermes_state_search.py`). The plugin's `search.py` opens
the session DB read-only and runs FTS5 queries.
- **`search` frame** → `search.results`:
- `scope:"all"` — search **everywhere** (all channels/threads/sessions).
- `scope:"chat"` — restrict to the given `chat_id` (and optional `thread_id`).
- **Result hit:** `{message_id, chat_id, thread_id, role, snippet, ts}`. The app
renders a results list; tapping a hit navigates to that channel/thread and
scrolls to + highlights the message.
- **Query syntax:** plain text (FTS5). Optional `role:` / `channel:` qualifiers
are a nice-to-have; v1 is plain-text + scope.
- **Privacy:** search is local to the user's own hermes home; no data leaves the
machine.
## 6.7 Channel list frame
`hello.ack` and `channel.*` frames carry the directory. App keeps a local copy
(Room) and reconciles on `channel.*` events (merge, don't clobber — see
`10-android-app.md` state rules).