Runtime footer: app-controlled model/context/cwd/latency/cost under replies

Hermes can append a text "runtime footer" (model, context %, workdir,
latency, cost) to final replies, but only when display.runtime_footer is
enabled in the hermes config. We want the same info but controlled by the
APP, not the gateway config. So the gateway now ALWAYS sends the data as a
structured `runtime` object on final assistant messages, and the app decides
whether/what to show.

Gateway (gateway-plugin/):
- protocol.py: new runtime_footer() helper + `runtime` field on the
  message / message.stop frames. Keys (all optional, absent when the data
  is unavailable — e.g. no cost for local models): model (vendor prefix
  dropped), context_pct (0-100), cwd (home-relative), latency (seconds),
  cost (USD).
- adapter.py: a post_api_request plugin hook captures the turn's model +
  prompt tokens + start time (platform-filtered to android so other
  platforms don't pollute the buffer). _build_runtime_footer() resolves the
  model's context window (cached, best-effort, off the event loop via
  asyncio.to_thread with a timeout) and computes context_pct. The runtime
  object is attached on every final send (streaming message.stop and
  non-streaming message, plus the fallback paths).
- outbox.py: `runtime` preserved in history reconstruction so the footer
  survives a restart / first open.

App (app/shared/):
- Protocol.kt: RuntimeMeta data class + `runtime` on MessagePayload /
  MessageStopPayload / HistoryMessage.
- ChatStore.kt: `runtime` on MessageItem, wired through live + history
  reconciliation.
- SecureStore.kt (+ Android/Desktop actuals): runtimeFooterEnabled +
  runtimeFooterFields (persisted per device).
- IrisController.kt: StateFlows + toggleRuntimeFooter() /
  toggleRuntimeField(); RUNTIME_FIELD_KEYS / default set / parser.
- SettingsScreen.kt: "Runtime footer" switch; when on, an expandable chip
  menu (Model · Context % · Workdir · Latency · Cost) to pick fields.
- ChatScreen.kt: footer rendered on the SAME line as the timestamp (footer
  left, time right, Telegram-style), only for final non-streaming assistant
  answers; Inspector pane now shows the runtime fields too.

Docs: 04-wire-protocol.md + frames.schema.json document the `runtime`
object.

Verified end-to-end on device: final replies carry
`qwen3.8-27B-exl3-4.5bpw · 53% · ~ · 38s` with the time right-aligned on
the same line; 69/69 gateway tests pass, Kotlin builds + tests pass.
This commit is contained in:
ARIA committed 2026-08-21 19:32:05 +02:00
1 parent 678c0344c8
commit 9286937e2d
13 files changed
+854 -165

No files matched your search

@@ -162,6 +162,14 @@ class AndroidSecureStore(
get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f) get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f)
set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply() set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply()
override var runtimeFooterEnabled: Boolean
get() = prefs.getBoolean(KEY_RUNTIME_FOOTER_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_RUNTIME_FOOTER_ENABLED, value).apply()
override var runtimeFooterFields: String
get() = prefs.getString(KEY_RUNTIME_FOOTER_FIELDS, "").orEmpty()
set(value) = prefs.edit().putString(KEY_RUNTIME_FOOTER_FIELDS, value).apply()
override fun savePairing( override fun savePairing(
url: String, url: String,
token: String, token: String,
@@ -199,5 +207,7 @@ class AndroidSecureStore(
const val KEY_BACKGROUND_COLOR = "background_color" const val KEY_BACKGROUND_COLOR = "background_color"
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path" const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
const val KEY_FONT_SIZE_SCALE = "font_size_scale" const val KEY_FONT_SIZE_SCALE = "font_size_scale"
const val KEY_RUNTIME_FOOTER_ENABLED = "runtime_footer_enabled"
const val KEY_RUNTIME_FOOTER_FIELDS = "runtime_footer_fields"
} }
} }
@@ -10,6 +10,7 @@ import iris.protocol.MessageStopPayload
import iris.protocol.MessageUpdatePayload import iris.protocol.MessageUpdatePayload
import iris.protocol.ROLE_ASSISTANT import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER import iris.protocol.ROLE_USER
import iris.protocol.RuntimeMeta
import iris.protocol.TYPE_COMMENTARY import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MEDIA_OFFER import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE import iris.protocol.TYPE_MESSAGE
@@ -66,6 +67,7 @@ data class MessageItem(
val streaming: Boolean = false, val streaming: Boolean = false,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val media: List<MediaItem> = emptyList(), val media: List<MediaItem> = emptyList(),
val isSystem: Boolean = false, val isSystem: Boolean = false,
) : ChatItem ) : ChatItem
@@ -119,16 +121,17 @@ class ChatStore {
companion object { companion object {
const val DEFAULT_LANE = "android:default" const val DEFAULT_LANE = "android:default"
fun randomId(prefix: String): String = fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
} }
// ── Lane helpers ────────────────────────────────────────────────────── // ── Lane helpers ──────────────────────────────────────────────────────
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because /** Lane key for a (chat, thread) pair. Uses `::` as the separator because
* chat ids already contain a single `:` (e.g. `android:chan_1`). */ * chat ids already contain a single `:` (e.g. `android:chan_1`). */
fun laneKey(chatId: String, threadId: String?): String = fun laneKey(
if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId" chatId: String,
threadId: String?,
): String = if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
/** Split a lane key back into (chatId, threadId). */ /** Split a lane key back into (chatId, threadId). */
fun parseLane(key: String): Pair<String, String?> { fun parseLane(key: String): Pair<String, String?> {
@@ -145,7 +148,10 @@ class ChatStore {
return laneKey(chatId, frame.threadId) return laneKey(chatId, frame.threadId)
} }
private fun updateLane(lane: String, transform: (List<ChatItem>) -> List<ChatItem>) { private fun updateLane(
lane: String,
transform: (List<ChatItem>) -> List<ChatItem>,
) {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty()) map[lane] = transform(map[lane].orEmpty())
_lanes.value = map _lanes.value = map
@@ -154,7 +160,11 @@ class ChatStore {
// ── Optimistic send ─────────────────────────────────────────────────── // ── Optimistic send ───────────────────────────────────────────────────
/** Optimistic add: show the user's message immediately (pending) in [lane]. */ /** Optimistic add: show the user's message immediately (pending) in [lane]. */
fun addPending(text: String, lane: String, media: List<MediaItem> = emptyList()): String { fun addPending(
text: String,
lane: String,
media: List<MediaItem> = emptyList(),
): String {
localSeq++ localSeq++
val id = "local_$localSeq" val id = "local_$localSeq"
updateLane(lane) { updateLane(lane) {
@@ -166,7 +176,10 @@ class ChatStore {
/** Append a locally generated, centered system notice (gateway restart / /** Append a locally generated, centered system notice (gateway restart /
* online) to [lane]. Not a user or agent turn: it is not selectable, * online) to [lane]. Not a user or agent turn: it is not selectable,
* deletable, or echoed to the server. */ * deletable, or echoed to the server. */
fun addSystemMessage(lane: String, text: String) { fun addSystemMessage(
lane: String,
text: String,
) {
localSeq++ localSeq++
val id = "sys_$localSeq" val id = "sys_$localSeq"
updateLane(lane) { updateLane(lane) {
@@ -195,7 +208,10 @@ class ChatStore {
// ── message (final / standalone, incl. non-streaming + user echo) ───── // ── message (final / standalone, incl. non-streaming + user echo) ─────
private fun onMessage(lane: String, frame: Frame) { private fun onMessage(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessagePayload>() ?: return val p = frame.payloadAs<MessagePayload>() ?: return
// Auto-threaded send: the gateway created a fresh thread for this // Auto-threaded send: the gateway created a fresh thread for this
// message; the optimistic bubble is still in the parent flat lane. // message; the optimistic bubble is still in the parent flat lane.
@@ -205,7 +221,8 @@ class ChatStore {
val byId = list.indexOfFirst { it.id == p.messageId } val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) { if (byId >= 0) {
val cur = list[byId] as? MessageItem ?: return@updateLane list val cur = list[byId] as? MessageItem ?: return@updateLane list
val updated = cur.copy( val updated =
cur.copy(
text = p.text, text = p.text,
reasoning = p.reasoning, reasoning = p.reasoning,
pending = false, pending = false,
@@ -213,34 +230,54 @@ class ChatStore {
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent, status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
model = p.model, model = p.model,
tokens = p.tokens, tokens = p.tokens,
runtime = p.runtime ?: cur.runtime,
ts = p.ts ?: cur.ts, ts = p.ts ?: cur.ts,
media = mergeMedia(cur.media, p.media), media = mergeMedia(cur.media, p.media),
) )
list.toMutableList().also { it[byId] = updated } list.toMutableList().also { it[byId] = updated }
} else if (p.role == ROLE_USER) { } else if (p.role == ROLE_USER) {
// Replace the matching optimistic pending bubble (server echo). // Replace the matching optimistic pending bubble (server echo).
val pendingIdx = list.indexOfLast { val pendingIdx =
list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
} }
if (pendingIdx >= 0) { if (pendingIdx >= 0) {
list.toMutableList().also { list.toMutableList().also {
it[pendingIdx] = MessageItem( it[pendingIdx] =
id = p.messageId, role = p.role, text = p.text, MessageItem(
ts = p.ts ?: 0, reasoning = p.reasoning, id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media), media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media),
) )
} }
} else { } else {
list + MessageItem( list +
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0, MessageItem(
reasoning = p.reasoning, model = p.model, tokens = p.tokens, id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
media = p.media.map { it.toMediaItem() }, media = p.media.map { it.toMediaItem() },
) )
} }
} else { } else {
list + MessageItem( list +
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0, MessageItem(
reasoning = p.reasoning, model = p.model, tokens = p.tokens, id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
media = p.media.map { it.toMediaItem() }, media = p.media.map { it.toMediaItem() },
) )
} }
@@ -254,13 +291,17 @@ class ChatStore {
* it in the thread lane. No-op when [lane] is not a thread lane or no * it in the thread lane. No-op when [lane] is not a thread lane or no
* pending bubble with the same text exists in the flat lane. * pending bubble with the same text exists in the flat lane.
*/ */
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) { private fun relocatePendingToThreadLane(
lane: String,
p: MessagePayload,
) {
val (chatId, threadId) = parseLane(lane) val (chatId, threadId) = parseLane(lane)
if (threadId == null) return if (threadId == null) return
val flatLane = chatId val flatLane = chatId
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty() val flatList = map[flatLane].orEmpty()
val idx = flatList.indexOfLast { val idx =
flatList.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
} }
if (idx < 0) return if (idx < 0) return
@@ -269,35 +310,54 @@ class ChatStore {
} }
/** Merge server media refs into existing items, keeping local paths. */ /** Merge server media refs into existing items, keeping local paths. */
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> { private fun mergeMedia(
existing: List<MediaItem>,
incoming: List<MediaRef>,
): List<MediaItem> {
if (incoming.isEmpty()) return existing if (incoming.isEmpty()) return existing
val byId = existing.associateBy { it.mediaId } val byId = existing.associateBy { it.mediaId }
return incoming.map { ref -> return incoming.map { ref ->
byId[ref.mediaId]?.copy( byId[ref.mediaId]?.copy(
kind = ref.kind, mime = ref.mime, size = ref.size, filename = ref.filename, kind = ref.kind,
mime = ref.mime,
size = ref.size,
filename = ref.filename,
) ?: ref.toMediaItem() ) ?: ref.toMediaItem()
} }
} }
private fun MediaRef.toMediaItem() = private fun MediaRef.toMediaItem() = MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
// ── message.start (open a live streaming bubble) ────────────────────── // ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(lane: String, frame: Frame) { private fun onMessageStart(
lane: String,
frame: Frame,
) {
if (!streamingEnabled) return if (!streamingEnabled) return
val p = frame.payloadAs<MessageStartPayload>() ?: return val p = frame.payloadAs<MessageStartPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list if (list.any { it.id == p.messageId }) {
else list + MessageItem( list
id = p.messageId, role = p.role, text = "", ts = 0, streaming = true, } else {
list +
MessageItem(
id = p.messageId,
role = p.role,
text = "",
ts = 0,
streaming = true,
) )
} }
} }
}
// ── message.update (replace live bubble text; full snapshot) ────────── // ── message.update (replace live bubble text; full snapshot) ──────────
private fun onMessageUpdate(lane: String, frame: Frame) { private fun onMessageUpdate(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessageUpdatePayload>() ?: return val p = frame.payloadAs<MessageUpdatePayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId } val idx = list.indexOfFirst { it.id == p.messageId }
@@ -309,25 +369,36 @@ class ChatStore {
// ── message.stop (finalize the live bubble) ─────────────────────────── // ── message.stop (finalize the live bubble) ───────────────────────────
private fun onMessageStop(lane: String, frame: Frame) { private fun onMessageStop(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessageStopPayload>() ?: return val p = frame.payloadAs<MessageStopPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId } val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) { if (idx < 0) {
// No live bubble (e.g. missed start) — materialize a final one. // No live bubble (e.g. missed start) — materialize a final one.
list + MessageItem( list +
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText, MessageItem(
ts = p.ts ?: 0, reasoning = p.reasoning, id = p.messageId,
model = p.model, tokens = p.tokens, role = ROLE_ASSISTANT,
text = p.finalText,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
) )
} else { } else {
val cur = list[idx] as? MessageItem ?: return@updateLane list val cur = list[idx] as? MessageItem ?: return@updateLane list
list.toMutableList().also { list.toMutableList().also {
it[idx] = cur.copy( it[idx] =
cur.copy(
text = p.finalText, text = p.finalText,
reasoning = p.reasoning, reasoning = p.reasoning,
model = p.model, model = p.model,
tokens = p.tokens, tokens = p.tokens,
runtime = p.runtime ?: cur.runtime,
streaming = false, streaming = false,
ts = p.ts ?: cur.ts, ts = p.ts ?: cur.ts,
) )
@@ -338,21 +409,31 @@ class ChatStore {
// ── tool.start (new tool card) ──────────────────────────────────────── // ── tool.start (new tool card) ────────────────────────────────────────
private fun onToolStart(lane: String, frame: Frame) { private fun onToolStart(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolStartPayload>() ?: return val p = frame.payloadAs<ToolStartPayload>() ?: return
toolSeq++ toolSeq++
val id = "tool_$toolSeq" val id = "tool_$toolSeq"
updateLane(lane) { list -> updateLane(lane) { list ->
list + ToolItem( list +
id = id, index = p.index, name = p.name, ToolItem(
preview = p.preview, args = p.args, id = id,
index = p.index,
name = p.name,
preview = p.preview,
args = p.args,
) )
} }
} }
// ── tool.progress (in-progress note) ────────────────────────────────── // ── tool.progress (in-progress note) ──────────────────────────────────
private fun onToolProgress(lane: String, frame: Frame) { private fun onToolProgress(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolProgressPayload>() ?: return val p = frame.payloadAs<ToolProgressPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index } val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
@@ -364,15 +445,21 @@ class ChatStore {
// ── tool.end (mark the tool card complete) ──────────────────────────── // ── tool.end (mark the tool card complete) ────────────────────────────
private fun onToolEnd(lane: String, frame: Frame) { private fun onToolEnd(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolEndPayload>() ?: return val p = frame.payloadAs<ToolEndPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index } val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return@updateLane list if (idx < 0) return@updateLane list
val cur = list[idx] as ToolItem val cur = list[idx] as ToolItem
list.toMutableList().also { list.toMutableList().also {
it[idx] = cur.copy( it[idx] =
done = true, ok = p.ok, duration = p.duration, cur.copy(
done = true,
ok = p.ok,
duration = p.duration,
outputPreview = p.outputPreview, outputPreview = p.outputPreview,
) )
} }
@@ -381,7 +468,10 @@ class ChatStore {
// ── commentary (dimmed interim beat) ────────────────────────────────── // ── commentary (dimmed interim beat) ──────────────────────────────────
private fun onCommentary(lane: String, frame: Frame) { private fun onCommentary(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<CommentaryPayload>() ?: return val p = frame.payloadAs<CommentaryPayload>() ?: return
// Gateway lifecycle notices (restart / shutdown / online) are rendered // Gateway lifecycle notices (restart / shutdown / online) are rendered
// as a centered system notice by the controller, on the down/up state // as a centered system notice by the controller, on the down/up state
@@ -392,13 +482,20 @@ class ChatStore {
// and prevents duplicates. // and prevents duplicates.
if (isGatewayLifecycleNotice(p.text)) return if (isGatewayLifecycleNotice(p.text)) return
updateLane(lane) { list -> updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list if (list.any { it.id == p.messageId }) {
else list + MessageItem( list
id = p.messageId, role = ROLE_ASSISTANT, text = p.text, } else {
ts = 0, isCommentary = true, list +
MessageItem(
id = p.messageId,
role = ROLE_ASSISTANT,
text = p.text,
ts = 0,
isCommentary = true,
) )
} }
} }
}
/** True for hermes gateway lifecycle notices (same wording on all apps). */ /** True for hermes gateway lifecycle notices (same wording on all apps). */
private fun isGatewayLifecycleNotice(text: String): Boolean { private fun isGatewayLifecycleNotice(text: String): Boolean {
@@ -415,16 +512,24 @@ class ChatStore {
* falling back to the lane's last assistant message). The item has no local * falling back to the lane's last assistant message). The item has no local
* path yet — the controller pulls it and calls [setMediaLocalPath]. * path yet — the controller pulls it and calls [setMediaLocalPath].
*/ */
private fun onMediaOffer(lane: String, frame: Frame) { private fun onMediaOffer(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MediaOfferPayload>() ?: return val p = frame.payloadAs<MediaOfferPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
// Already attached (offer replayed)? Skip. // Already attached (offer replayed)? Skip.
if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list
val item = MediaItem( val item =
mediaId = p.mediaId, kind = p.kind, mime = p.mime, MediaItem(
size = p.size, filename = p.filename, mediaId = p.mediaId,
kind = p.kind,
mime = p.mime,
size = p.size,
filename = p.filename,
) )
val targetIdx = if (p.messageId != null) { val targetIdx =
if (p.messageId != null) {
list.indexOfFirst { it.id == p.messageId } list.indexOfFirst { it.id == p.messageId }
} else { } else {
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT } list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
@@ -436,16 +541,25 @@ class ChatStore {
} }
/** Record a pulled file's local path for [mediaId] (all lanes). */ /** Record a pulled file's local path for [mediaId] (all lanes). */
fun setMediaLocalPath(mediaId: String, localPath: String) { fun setMediaLocalPath(
mediaId: String,
localPath: String,
) {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
var changed = false var changed = false
for ((lane, list) in map) { for ((lane, list) in map) {
val updated = list.map { item -> val updated =
list.map { item ->
if (item is MessageItem) { if (item is MessageItem) {
item.copy(media = item.media.map { m -> item.copy(
media =
item.media.map { m ->
if (m.mediaId == mediaId) m.copy(localPath = localPath) else m if (m.mediaId == mediaId) m.copy(localPath = localPath) else m
}) },
} else item )
} else {
item
}
} }
if (updated != list) { if (updated != list) {
map[lane] = updated map[lane] = updated
@@ -460,12 +574,15 @@ class ChatStore {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
var changed = false var changed = false
for ((lane, list) in map) { for ((lane, list) in map) {
val updated = list.map { item -> val updated =
list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER && if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read item.status != MsgStatus.Read
) { ) {
item.copy(pending = false, status = MsgStatus.Read) item.copy(pending = false, status = MsgStatus.Read)
} else item } else {
item
}
} }
if (updated != list) { if (updated != list) {
map[lane] = updated map[lane] = updated
@@ -506,7 +623,8 @@ class ChatStore {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
var changed = false var changed = false
for ((lane, list) in map) { for ((lane, list) in map) {
val updated = list.map { item -> val updated =
list.map { item ->
when (item) { when (item) {
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
@@ -525,10 +643,13 @@ class ChatStore {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
var changed = false var changed = false
for ((lane, list) in map) { for ((lane, list) in map) {
val updated = list.map { item -> val updated =
list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) { if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
item.copy(pending = false, status = MsgStatus.Failed) item.copy(pending = false, status = MsgStatus.Failed)
} else item } else {
item
}
} }
if (updated != list) { if (updated != list) {
map[lane] = updated map[lane] = updated
@@ -539,12 +660,17 @@ class ChatStore {
} }
/** M7: re-arm a failed user message for a retry send. */ /** M7: re-arm a failed user message for a retry send. */
fun rearmForRetry(lane: String, messageId: String) { fun rearmForRetry(
lane: String,
messageId: String,
) {
updateLane(lane) { list -> updateLane(lane) { list ->
list.map { item -> list.map { item ->
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) { if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
item.copy(pending = true, status = MsgStatus.Pending) item.copy(pending = true, status = MsgStatus.Pending)
} else item } else {
item
}
} }
} }
} }
@@ -558,10 +684,14 @@ class ChatStore {
* the in-memory store is empty and the `sync` delta does not cover older * the in-memory store is empty and the `sync` delta does not cover older
* messages. * messages.
*/ */
fun loadHistory(lane: String, messages: List<MessageItem>) { fun loadHistory(
lane: String,
messages: List<MessageItem>,
) {
updateLane(lane) { list -> updateLane(lane) { list ->
val historyIds = messages.map { it.id }.toSet() val historyIds = messages.map { it.id }.toSet()
val preserved = list.filter { item -> val preserved =
list.filter { item ->
item !is MessageItem || item.id !in historyIds item !is MessageItem || item.id !in historyIds
} }
(messages + preserved).sortedBy { item -> (messages + preserved).sortedBy { item ->
@@ -66,6 +66,15 @@ interface SecureStore {
* system font scale). */ * system font scale). */
var fontSizeScale: Float var fontSizeScale: Float
/** UI setting: show the runtime-metadata footer under final assistant
* messages (model / context % / cwd / latency / cost). */
var runtimeFooterEnabled: Boolean
/** UI setting: which runtime-footer fields to show, as a comma-separated
* list in display order (e.g. "model,context_pct,cwd"). Empty = the
* default set. Valid keys: model, context_pct, cwd, latency, cost. */
var runtimeFooterFields: String
fun savePairing( fun savePairing(
url: String, url: String,
token: String, token: String,
@@ -20,7 +20,8 @@ import kotlinx.serialization.json.put
const val PROTOCOL_VERSION = 1 const val PROTOCOL_VERSION = 1
object IrisJson { object IrisJson {
val instance: Json = Json { val instance: Json =
Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
encodeDefaults = true encodeDefaults = true
isLenient = true isLenient = true
@@ -198,6 +199,22 @@ data class HelloAckPayload(
// ── message (server -> app) ───────────────────────────────────────────── // ── message (server -> app) ─────────────────────────────────────────────
/**
* Structured runtime metadata for the app's footer (mirror of the gateway's
* `runtime` object). The gateway ALWAYS sends this on final assistant
* messages; whether/what is shown is a per-app setting (Settings → Runtime
* footer), NOT a hermes config. Fields are absent when the data is
* unavailable (local models have no cost, etc.).
*/
@Serializable
data class RuntimeMeta(
val model: String? = null,
@SerialName("context_pct") val contextPct: Int? = null,
val cwd: String? = null,
val latency: Double? = null,
val cost: Double? = null,
)
@Serializable @Serializable
data class MessagePayload( data class MessagePayload(
@SerialName("message_id") val messageId: String, @SerialName("message_id") val messageId: String,
@@ -207,6 +224,7 @@ data class MessagePayload(
@SerialName("reply_to") val replyTo: String? = null, @SerialName("reply_to") val replyTo: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
val media: List<MediaRef> = emptyList(), val media: List<MediaRef> = emptyList(),
) )
@@ -260,7 +278,9 @@ data class MediaPullPayload(
) )
@Serializable @Serializable
data class MediaPullEndPayload(val ok: Boolean) data class MediaPullEndPayload(
val ok: Boolean,
)
// ── M2: streaming frames (server -> app) ──────────────────────────────── // ── M2: streaming frames (server -> app) ────────────────────────────────
@@ -283,6 +303,7 @@ data class MessageStopPayload(
val reasoning: String? = null, val reasoning: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
) )
@@ -335,13 +356,20 @@ data class MessageSendPayload(
// ── typing / error / ping ─────────────────────────────────────────────── // ── typing / error / ping ───────────────────────────────────────────────
@Serializable @Serializable
data class TypingPayload(val on: Boolean) data class TypingPayload(
val on: Boolean,
)
@Serializable @Serializable
data class ErrorPayload(val code: String, val message: String) data class ErrorPayload(
val code: String,
val message: String,
)
@Serializable @Serializable
data class PingPayload(val ts: Long? = null) data class PingPayload(
val ts: Long? = null,
)
// ── M3: channel directory (app -> server requests) ────────────────────── // ── M3: channel directory (app -> server requests) ──────────────────────
@@ -353,13 +381,19 @@ data class ChannelCreatePayload(
) )
@Serializable @Serializable
data class ChannelRenamePayload(val name: String) data class ChannelRenamePayload(
val name: String,
)
@Serializable @Serializable
data class ChannelFavoritePayload(val on: Boolean) data class ChannelFavoritePayload(
val on: Boolean,
)
@Serializable @Serializable
data class ChannelSetAutomationPayload(val on: Boolean) data class ChannelSetAutomationPayload(
val on: Boolean,
)
@Serializable @Serializable
data class ChannelIconPayload( data class ChannelIconPayload(
@@ -370,10 +404,14 @@ data class ChannelIconPayload(
) )
@Serializable @Serializable
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList()) data class ChannelListPayload(
val channels: List<ChannelInfo> = emptyList(),
)
@Serializable @Serializable
data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String) data class ChannelDeletedPayload(
@SerialName("chat_id") val chatId: String,
)
// ── M3: search ────────────────────────────────────────────────────────── // ── M3: search ──────────────────────────────────────────────────────────
@@ -424,10 +462,14 @@ data class CommandsCatalogPayload(
// ── M3: sync (reconnect catch-up) ─────────────────────────────────────── // ── M3: sync (reconnect catch-up) ───────────────────────────────────────
@Serializable @Serializable
data class SyncPayload(val cursor: Long) data class SyncPayload(
val cursor: Long,
)
@Serializable @Serializable
data class SyncDonePayload(val cursor: Long) data class SyncDonePayload(
val cursor: Long,
)
// ── history (full message history for a chat/thread) ──────────────────── // ── history (full message history for a chat/thread) ────────────────────
@@ -440,6 +482,7 @@ data class HistoryMessage(
val reasoning: String? = null, val reasoning: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
val media: List<MediaRef> = emptyList(), val media: List<MediaRef> = emptyList(),
) )
@@ -496,7 +539,9 @@ data class ReadReceiptPayload(
) )
@Serializable @Serializable
data class StatusPayload(val state: String) data class StatusPayload(
val state: String,
)
// ── Frame builders ────────────────────────────────────────────────────── // ── Frame builders ──────────────────────────────────────────────────────
@@ -509,7 +554,8 @@ fun helloFrame(
): Frame = ): Frame =
Frame( Frame(
type = TYPE_HELLO, type = TYPE_HELLO,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(), HelloPayload.serializer(),
HelloPayload( HelloPayload(
token = token, token = token,
@@ -534,79 +580,109 @@ fun messageSendFrame(
type = TYPE_MESSAGE_SEND, type = TYPE_MESSAGE_SEND,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(), MessageSendPayload.serializer(),
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread), MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
), ),
) )
fun pingFrame(): Frame = fun pingFrame(): Frame = Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
// ── M3 frame builders ─────────────────────────────────────────────────── // ── M3 frame builders ───────────────────────────────────────────────────
fun channelCreateFrame(id: Int, name: String, kind: String = "channel", parentChatId: String? = null): Frame = fun channelCreateFrame(
id: Int,
name: String,
kind: String = "channel",
parentChatId: String? = null,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_CREATE, type = TYPE_CHANNEL_CREATE,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelCreatePayload.serializer(), ChannelCreatePayload.serializer(),
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId), ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
), ),
) )
fun channelRenameFrame(id: Int, chatId: String, name: String): Frame = fun channelRenameFrame(
id: Int,
chatId: String,
name: String,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_RENAME, type = TYPE_CHANNEL_RENAME,
chatId = chatId, chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelRenamePayload.serializer(), ChannelRenamePayload.serializer(),
ChannelRenamePayload(name = name), ChannelRenamePayload(name = name),
), ),
) )
fun channelSetDefaultFrame(id: Int, chatId: String): Frame = fun channelSetDefaultFrame(
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId) id: Int,
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame = fun channelFavoriteFrame(
id: Int,
chatId: String,
on: Boolean,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_FAVORITE, type = TYPE_CHANNEL_FAVORITE,
chatId = chatId, chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelFavoritePayload.serializer(), ChannelFavoritePayload.serializer(),
ChannelFavoritePayload(on = on), ChannelFavoritePayload(on = on),
), ),
) )
fun channelSetAutomationFrame(id: Int, chatId: String, on: Boolean): Frame = fun channelSetAutomationFrame(
id: Int,
chatId: String,
on: Boolean,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_SET_AUTOMATION, type = TYPE_CHANNEL_SET_AUTOMATION,
chatId = chatId, chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelSetAutomationPayload.serializer(), ChannelSetAutomationPayload.serializer(),
ChannelSetAutomationPayload(on = on), ChannelSetAutomationPayload(on = on),
), ),
) )
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame = fun channelIconFrame(
id: Int,
chatId: String,
icon: String?,
color: String?,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_ICON, type = TYPE_CHANNEL_ICON,
chatId = chatId, chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelIconPayload.serializer(), ChannelIconPayload.serializer(),
ChannelIconPayload(icon = icon, color = color), ChannelIconPayload(icon = icon, color = color),
), ),
) )
fun channelDeleteFrame(id: Int, chatId: String): Frame = fun channelDeleteFrame(
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId) id: Int,
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
fun channelListFrame(id: Int): Frame = fun channelListFrame(id: Int): Frame = Frame(id = id, type = TYPE_CHANNEL_LIST)
Frame(id = id, type = TYPE_CHANNEL_LIST)
fun searchFrame( fun searchFrame(
id: Int, id: Int,
@@ -620,7 +696,8 @@ fun searchFrame(
type = TYPE_SEARCH, type = TYPE_SEARCH,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
SearchPayload.serializer(), SearchPayload.serializer(),
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId), SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
), ),
@@ -628,14 +705,17 @@ fun searchFrame(
/** Request the gateway's slash-command catalog (the composer's "/" drawer). /** Request the gateway's slash-command catalog (the composer's "/" drawer).
* Answered by a `commands.catalog` frame carrying the same id. */ * Answered by a `commands.catalog` frame carrying the same id. */
fun commandsCatalogFrame(id: Int): Frame = fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
Frame(id = id, type = TYPE_COMMANDS_CATALOG)
fun syncFrame(id: Int, cursor: Long): Frame = fun syncFrame(
id: Int,
cursor: Long,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_SYNC, type = TYPE_SYNC,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
SyncPayload.serializer(), SyncPayload.serializer(),
SyncPayload(cursor = cursor), SyncPayload(cursor = cursor),
), ),
@@ -656,7 +736,8 @@ fun historyFrame(
type = TYPE_HISTORY, type = TYPE_HISTORY,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = buildJsonObject { payload =
buildJsonObject {
if (beforeMessageId != null) put("before_message_id", beforeMessageId) if (beforeMessageId != null) put("before_message_id", beforeMessageId)
put("limit", limit) put("limit", limit)
}, },
@@ -675,7 +756,8 @@ fun messageDeleteFrame(
type = TYPE_MESSAGE_DELETE, type = TYPE_MESSAGE_DELETE,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = buildJsonObject { payload =
buildJsonObject {
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) })) put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
}, },
) )
@@ -693,27 +775,37 @@ fun mediaUploadStartFrame(
Frame( Frame(
id = id, id = id,
type = TYPE_MEDIA_UPLOAD_START, type = TYPE_MEDIA_UPLOAD_START,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
MediaUploadStartPayload.serializer(), MediaUploadStartPayload.serializer(),
MediaUploadStartPayload(mediaRef, kind, mime, filename, size), MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
), ),
) )
fun mediaUploadEndFrame(id: Int, mediaRef: String, sha256: String): Frame = fun mediaUploadEndFrame(
id: Int,
mediaRef: String,
sha256: String,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_MEDIA_UPLOAD_END, type = TYPE_MEDIA_UPLOAD_END,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
MediaUploadEndPayload.serializer(), MediaUploadEndPayload.serializer(),
MediaUploadEndPayload(mediaRef, sha256), MediaUploadEndPayload(mediaRef, sha256),
), ),
) )
fun mediaPullFrame(id: Int, mediaId: String): Frame = fun mediaPullFrame(
id: Int,
mediaId: String,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_MEDIA_PULL, type = TYPE_MEDIA_PULL,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
MediaPullPayload.serializer(), MediaPullPayload.serializer(),
MediaPullPayload(mediaId), MediaPullPayload(mediaId),
), ),
@@ -721,10 +813,14 @@ fun mediaPullFrame(id: Int, mediaId: String): Frame =
// ── M5 frame builders ─────────────────────────────────────────────────── // ── M5 frame builders ───────────────────────────────────────────────────
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame = fun fcmRegisterFrame(
fcmToken: String? = null,
ntfyTopic: String? = null,
): Frame =
Frame( Frame(
type = TYPE_FCM_REGISTER, type = TYPE_FCM_REGISTER,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
FcmRegisterPayload.serializer(), FcmRegisterPayload.serializer(),
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic), FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
), ),
@@ -146,8 +146,7 @@ class IrisController(
/** True while [frame] is a sync replay that already reached the device /** True while [frame] is a sync replay that already reached the device
* via push (live frames carry no cursor and are never suppressed). */ * via push (live frames carry no cursor and are never suppressed). */
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false
frame.cursor?.let { it <= lastPushedCursor } ?: false
// ── M3: threads toggle (per-app for now; per-channel lands later) ───── // ── M3: threads toggle (per-app for now; per-channel lands later) ─────
// Persisted (Settings → "Threads"). // Persisted (Settings → "Threads").
@@ -183,6 +182,48 @@ class IrisController(
store.reasoningAutoCollapse = _reasoningAutoCollapse.value store.reasoningAutoCollapse = _reasoningAutoCollapse.value
} }
// ── Runtime-metadata footer (Settings → "Runtime footer") ───────────────────────────────────────────────
// The gateway ALWAYS sends the structured runtime metadata on final
// assistant messages; these settings control whether/what the app shows
// (per-device display preference, like tool verbosity). [runtimeFooterFields]
// is a comma-separated list of field keys in display order.
private val _runtimeFooterEnabled = MutableStateFlow(store.runtimeFooterEnabled)
val runtimeFooterEnabled: StateFlow<Boolean> = _runtimeFooterEnabled.asStateFlow()
private val _runtimeFooterFields = MutableStateFlow(parseRuntimeFields(store.runtimeFooterFields))
val runtimeFooterFields: StateFlow<List<String>> = _runtimeFooterFields.asStateFlow()
fun toggleRuntimeFooter() {
_runtimeFooterEnabled.value = !_runtimeFooterEnabled.value
store.runtimeFooterEnabled = _runtimeFooterEnabled.value
}
/** Toggle a single runtime-footer field on/off (preserving order). */
fun toggleRuntimeField(field: String) {
val current = _runtimeFooterFields.value
val updated = if (field in current) current - field else current + field
_runtimeFooterFields.value = updated
store.runtimeFooterFields = updated.joinToString(",")
}
companion object {
const val FONT_SCALE_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f
/** Valid runtime-footer field keys (mirror of the gateway's RUNTIME_FIELDS). */
val RUNTIME_FIELD_KEYS = listOf("model", "context_pct", "cwd", "latency", "cost")
/** Default footer fields (in display order) when the user has none set. */
val RUNTIME_FIELDS_DEFAULT = listOf("model", "context_pct", "cwd")
/** Parse a persisted comma-separated field list; unknown keys dropped,
* order preserved, empty falls back to the default set. */
fun parseRuntimeFields(raw: String): List<String> {
val parsed = raw.split(",").map { it.trim() }.filter { it in RUNTIME_FIELD_KEYS }
return if (parsed.isEmpty()) RUNTIME_FIELDS_DEFAULT else parsed
}
}
// ── Appearance (Settings → Appearance) ──────────────────────────────── // ── Appearance (Settings → Appearance) ────────────────────────────────
// User-customizable bubble colors + background (color or image). // User-customizable bubble colors + background (color or image).
// Persisted as ARGB ints + an image path (see UserTheme). // Persisted as ARGB ints + an image path (see UserTheme).
@@ -252,11 +293,6 @@ class IrisController(
store.fontSizeScale = clamped store.fontSizeScale = clamped
} }
companion object {
const val FONT_SCALE_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f
}
// ── M3: search state ────────────────────────────────────────────────── // ── M3: search state ──────────────────────────────────────────────────
private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList()) private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList())
val searchResults: StateFlow<List<SearchHit>> = _searchResults.asStateFlow() val searchResults: StateFlow<List<SearchHit>> = _searchResults.asStateFlow()
@@ -915,6 +951,7 @@ private fun HistoryMessage.toMessageItem(): MessageItem =
reasoning = reasoning, reasoning = reasoning,
model = model, model = model,
tokens = tokens, tokens = tokens,
runtime = runtime,
media = media =
media.map { media.map {
MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename) MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename)
@@ -141,6 +141,7 @@ import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks import iris.util.preserveNewlinesAsHardBreaks
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** /**
* Chat screen (M3): channel drawer + topic switcher + search + settings, * Chat screen (M3): channel drawer + topic switcher + search + settings,
@@ -157,6 +158,8 @@ fun ChatScreen(controller: IrisController) {
val toolDetail by controller.toolDetail.collectAsState() val toolDetail by controller.toolDetail.collectAsState()
val threadsEnabled by controller.threadsEnabled.collectAsState() val threadsEnabled by controller.threadsEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val channels by controller.channels.channels.collectAsState() val channels by controller.channels.channels.collectAsState()
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane) val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
@@ -506,6 +509,8 @@ fun ChatScreen(controller: IrisController) {
} }
} }
}, },
runtimeFooterEnabled = runtimeFooterEnabled,
runtimeFooterFields = runtimeFooterFields,
) )
} }
} }
@@ -1776,8 +1781,11 @@ private fun InspectorPane(
InfoRow("Threads", threads.size.toString()) InfoRow("Threads", threads.size.toString())
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp)) Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
InfoRow("Model", lastAssistant?.model ?: "—") InfoRow("Model", lastAssistant?.runtime?.model ?: "—")
InfoRow("Tokens", lastAssistant?.tokens?.toString() ?: "—") InfoRow("Context", lastAssistant?.runtime?.contextPct?.let { "$it%" } ?: "—")
InfoRow("Workdir", lastAssistant?.runtime?.cwd ?: "—")
InfoRow("Latency", lastAssistant?.runtime?.latency?.let { formatLatency(it) } ?: "—")
InfoRow("Cost", lastAssistant?.runtime?.cost?.let { formatCost(it) } ?: "—")
} }
} }
@@ -2211,6 +2219,8 @@ private fun MessageBubble(
selected: Boolean = false, selected: Boolean = false,
onToggleSelect: () -> Unit = {}, onToggleSelect: () -> Unit = {},
onLongPress: () -> Unit = {}, onLongPress: () -> Unit = {},
runtimeFooterEnabled: Boolean = false,
runtimeFooterFields: List<String> = emptyList(),
) { ) {
val isUser = msg.role == ROLE_USER val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary val isCommentary = msg.isCommentary
@@ -2345,32 +2355,38 @@ private fun MessageBubble(
} }
} }
if (!isUser) { if (!isUser) {
// Model / token footer (final assistant answers only). // Runtime-metadata footer + timestamp on ONE line (footer left,
if (!isCommentary && !msg.streaming && // time right) — Telegram-style. The app controls whether/what
(msg.model != null || msg.tokens != null) // the footer shows via Settings → Runtime footer.
) { val footerText =
Spacer(modifier = Modifier.height(4.dp)) if (!isCommentary && !msg.streaming && runtimeFooterEnabled) {
Text( buildRuntimeFooterText(msg, runtimeFooterFields)
buildString { } else {
msg.model?.let { append(it) } ""
if (msg.model != null && msg.tokens != null) append(" · ")
msg.tokens?.let { append("$it tok") }
},
color = textColor.copy(alpha = 0.4f),
fontSize = 10.sp,
)
} }
// M7: timestamp below the footer (reference shows "12:41"). if (footerText.isNotEmpty() || time.isNotEmpty()) {
if (time.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (footerText.isNotEmpty()) {
Text(
footerText,
color = textColor.copy(alpha = 0.4f),
fontSize = 10.sp,
modifier = Modifier.weight(1f),
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
if (time.isNotEmpty()) {
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f)) Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f))
} }
} }
} }
} }
}
if (!isUser && selectionMode) { if (!isUser && selectionMode) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
SelectionCheck(selected) SelectionCheck(selected)
@@ -2378,6 +2394,39 @@ private fun MessageBubble(
} }
} }
/** Build the runtime-footer text from the message's runtime metadata, showing
* only the enabled [fields] (in order) that have data. Empty when none. */
private fun buildRuntimeFooterText(
msg: MessageItem,
fields: List<String>,
): String {
val runtime = msg.runtime ?: return ""
val parts = mutableListOf<String>()
for (field in fields) {
when (field) {
"model" -> runtime.model?.let { parts.add(it) }
"context_pct" -> runtime.contextPct?.let { parts.add("$it%") }
"cwd" -> runtime.cwd?.let { parts.add(it) }
"latency" -> runtime.latency?.let { parts.add(formatLatency(it)) }
"cost" -> runtime.cost?.let { parts.add(formatCost(it)) }
}
}
return parts.joinToString(" · ")
}
/** Humanize a turn duration: `<1s`, `22s`, `1m05s` (matches hermes' footer). */
private fun formatLatency(seconds: Double): String {
if (seconds < 1) return "<1s"
val total = seconds.roundToInt()
if (total < 60) return "${total}s"
val m = total / 60
val s = total % 60
return "${m}m${s.toString().padStart(2, '0')}s"
}
/** Format a cost in USD: sub-cent at 4 decimals, else 2. */
private fun formatCost(cost: Double): String = if (cost < 0.01) "$%.4f".format(cost) else "$%.2f".format(cost)
/** Circular selection indicator shown beside a bubble in selection mode. */ /** Circular selection indicator shown beside a bubble in selection mode. */
@Composable @Composable
private fun SelectionCheck(selected: Boolean) { private fun SelectionCheck(selected: Boolean) {
@@ -72,6 +72,8 @@ fun SettingsScreen(
val threadsEnabled by controller.threadsEnabled.collectAsState() val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState() val streamingEnabled by controller.streamingEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val toolDetail by controller.toolDetail.collectAsState() val toolDetail by controller.toolDetail.collectAsState()
val fontSizeScale by controller.fontSizeScale.collectAsState() val fontSizeScale by controller.fontSizeScale.collectAsState()
val theme = LocalUserTheme.current val theme = LocalUserTheme.current
@@ -159,6 +161,39 @@ fun SettingsScreen(
) )
} }
} }
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("📊 Runtime footer", fontSize = 14.sp)
Text(
"Show model, context, workdir, latency and cost under replies",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = runtimeFooterEnabled,
onCheckedChange = { controller.toggleRuntimeFooter() },
)
}
if (runtimeFooterEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text("Fields to show", fontSize = 12.sp, color = IrisColors.textDim)
Spacer(modifier = Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
IrisController.RUNTIME_FIELD_KEYS.forEach { field ->
TopicChip(
label = runtimeFieldLabel(field),
selected = field in runtimeFooterFields,
onClick = { controller.toggleRuntimeField(field) },
)
}
}
}
}
Text( Text(
"Appearance", "Appearance",
@@ -620,3 +655,14 @@ private fun argbToHsv(argb: Int): FloatArray {
/** File name from a path (no java.io.File in common code). */ /** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') } private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
/** Human label for a runtime-footer field key (Settings → Runtime footer). */
private fun runtimeFieldLabel(field: String): String =
when (field) {
"model" -> "Model"
"context_pct" -> "Context %"
"cwd" -> "Workdir"
"latency" -> "Latency"
"cost" -> "Cost"
else -> field
}
@@ -48,6 +48,8 @@ class DesktopSecureStore : SecureStore {
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND, val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
val backgroundImagePath: String = Backdrop.DEFAULT.path, val backgroundImagePath: String = Backdrop.DEFAULT.path,
val fontSizeScale: Float = 1.0f, val fontSizeScale: Float = 1.0f,
val runtimeFooterEnabled: Boolean = false,
val runtimeFooterFields: String = "",
) )
init { init {
@@ -231,6 +233,20 @@ class DesktopSecureStore : SecureStore {
save(d.copy(fontSizeScale = value)) save(d.copy(fontSizeScale = value))
} }
override var runtimeFooterEnabled: Boolean
get() = load().runtimeFooterEnabled
set(value) {
val d = load()
save(d.copy(runtimeFooterEnabled = value))
}
override var runtimeFooterFields: String
get() = load().runtimeFooterFields
set(value) {
val d = load()
save(d.copy(runtimeFooterFields = value))
}
override fun savePairing( override fun savePairing(
url: String, url: String,
token: String, token: String,
+101 -2
View File
@@ -34,7 +34,9 @@ JSON `media.upload.end` / final ack. See `07-media.md`.
## Server → App (events / responses) ## Server → App (events / responses)
### `hello.ack` ### `hello.ack`
Pairing succeeded. Pairing succeeded.
```json ```json
{"type":"hello.ack","payload":{ {"type":"hello.ack","payload":{
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true, "server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true,
@@ -44,13 +46,16 @@ Pairing succeeded.
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}] "channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}]
}} }}
``` ```
`last_pushed_cursor` is the highest outbox cursor already delivered to THIS `last_pushed_cursor` is the highest outbox cursor already delivered to THIS
device via the push backend (0 = never). The app skips system notifications device via the push backend (0 = never). The app skips system notifications
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
woke the device via push (dedupe, `08-push.md` §8.7). woke the device via push (dedupe, `08-push.md` §8.7).
### `message` ### `message`
A final / standalone message. A final / standalone message.
```json ```json
{"type":"message","chat_id":"android:default","thread_id":null, {"type":"message","chat_id":"android:default","thread_id":null,
"payload":{ "payload":{
@@ -60,40 +65,65 @@ A final / standalone message.
"media":[{"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456, "media":[{"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,
"filename":"clip.mp4"}], // optional "filename":"clip.mp4"}], // optional
"reply_to":"m_8999", // optional "reply_to":"m_8999", // optional
"model":"qwen3-27b","tokens":11,"ts":1724000000000 "model":"qwen3-27b","tokens":11,"ts":1724000000000,
"runtime":{"model":"qwen3-27b","context_pct":38,"cwd":"~",
"latency":22.5,"cost":0.0012} // optional; see below
}} }}
``` ```
`role` ∈ `user | assistant | system | cron`. `reasoning` present only when the `role` ∈ `user | assistant | system | cron`. `reasoning` present only when the
agent produced reasoning and `show_reasoning` is on. agent produced reasoning and `show_reasoning` is on.
`runtime` (optional) is the **structured runtime-metadata footer** the app
renders under final assistant messages (Telegram-style). The gateway ALWAYS
sends it on final assistant messages; whether/what is shown is a **per-app
setting** (Settings → Runtime footer), NOT a hermes config. Keys (all
optional; absent when the data is unavailable, e.g. local models have no
cost):
- `model` — bare model id, vendor prefix dropped (`gpt-5.4`)
- `context_pct` — last-call context occupancy, 0-100 (int)
- `cwd` — home-relative working dir (`~`)
- `latency` — wall-clock turn duration, seconds (float)
- `cost` — turn cost, USD (float)
### `message.start` / `message.update` / `message.stop` ### `message.start` / `message.update` / `message.stop`
Streaming a bubble. `update` carries the **full** current text (app replaces). Streaming a bubble. `update` carries the **full** current text (app replaces).
```json ```json
{"type":"message.start","chat_id":"…","payload":{"message_id":"m_9002","role":"assistant"}} {"type":"message.start","chat_id":"…","payload":{"message_id":"m_9002","role":"assistant"}}
{"type":"message.update","chat_id":"…","payload":{"message_id":"m_9002","text":"partial…"}} {"type":"message.update","chat_id":"…","payload":{"message_id":"m_9002","text":"partial…"}}
{"type":"message.stop","chat_id":"…","payload":{"message_id":"m_9002","final_text":"full…", {"type":"message.stop","chat_id":"…","payload":{"message_id":"m_9002","final_text":"full…",
"reasoning":"…","model":"…","tokens":11}} "reasoning":"…","model":"…","tokens":11,
"runtime":{"model":"…","context_pct":38,"cwd":"~","latency":22.5}}}
``` ```
### `message.deleted` ### `message.deleted`
The given message(s) were deleted from a chat/thread. Response to a The given message(s) were deleted from a chat/thread. Response to a
`message.delete` request (`id` set) **and** broadcast to every device so all of `message.delete` request (`id` set) **and** broadcast to every device so all of
them drop the message(s) from their cache. Also outboxed, so a device that was them drop the message(s) from their cache. Also outboxed, so a device that was
offline learns of the deletion on its next `sync`. offline learns of the deletion on its next `sync`.
```json ```json
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null, {"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}} "payload":{"message_ids":["m_9001","m_9002"]}}
``` ```
### `commentary` ### `commentary`
Intermediate assistant beat (between tool iterations). Intermediate assistant beat (between tool iterations).
```json ```json
{"type":"commentary","chat_id":"…","payload":{"message_id":"m_9003","text":"Let me inspect the repo first."}} {"type":"commentary","chat_id":"…","payload":{"message_id":"m_9003","text":"Let me inspect the repo first."}}
``` ```
### `tool.start` / `tool.progress` / `tool.end` ### `tool.start` / `tool.progress` / `tool.end`
**Structured** tool events. The app decides how much to show (everything / **Structured** tool events. The app decides how much to show (everything /
truncated / nothing). truncated / nothing).
```json ```json
{"type":"tool.start","chat_id":"…","payload":{ {"type":"tool.start","chat_id":"…","payload":{
"index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"}}} "index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"}}}
@@ -101,24 +131,31 @@ truncated / nothing).
{"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4, {"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4,
"output_preview":"12 passed"}} "output_preview":"12 passed"}}
``` ```
`args` may be large; the app truncates per its setting. `output_preview` is a `args` may be large; the app truncates per its setting. `output_preview` is a
short tail (full output is not streamed — it lives in agent history). short tail (full output is not streamed — it lives in agent history).
### `typing` / `typing.stop` ### `typing` / `typing.stop`
```json ```json
{"type":"typing","chat_id":"…","payload":{"on":true}} {"type":"typing","chat_id":"…","payload":{"on":true}}
``` ```
### `notification` ### `notification`
In-app banner (foreground) and/or push mirror (background). In-app banner (foreground) and/or push mirror (background).
```json ```json
{"type":"notification","chat_id":"…","payload":{ {"type":"notification","chat_id":"…","payload":{
"kind":"channel_renamed","title":"ARIA","body":"Renamed topic to …","ts":1724000000000}} "kind":"channel_renamed","title":"ARIA","body":"Renamed topic to …","ts":1724000000000}}
``` ```
`kind` ∈ `channel_renamed | channel_created | cron | approval | clarify | generic`. `kind` ∈ `channel_renamed | channel_created | cron | approval | clarify | generic`.
### `picker.model` / `picker.choice` / `picker.clarify` / `picker.approval` / `picker.confirm` ### `picker.model` / `picker.choice` / `picker.clarify` / `picker.approval` / `picker.confirm`
Interactive prompts. App renders a native picker; answers via `picker.select`. Interactive prompts. App renders a native picker; answers via `picker.select`.
```json ```json
{"type":"picker.model","chat_id":"…","payload":{ {"type":"picker.model","chat_id":"…","payload":{
"picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local", "picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local",
@@ -129,19 +166,24 @@ Interactive prompts. App renders a native picker; answers via `picker.select`.
``` ```
### `channel.list` / `channel.created` / `channel.renamed` / `channel.deleted` ### `channel.list` / `channel.created` / `channel.renamed` / `channel.deleted`
Channel directory updates. **Broadcast to all connected devices** (no explicit Channel directory updates. **Broadcast to all connected devices** (no explicit
subscribe; the server pushes to every open WS). subscribe; the server pushes to every open WS).
```json ```json
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports", {"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports",
"kind":"channel","parent_chat_id":null}} "kind":"channel","parent_chat_id":null}}
``` ```
`channel.created` may carry `"auto":true` for a thread the gateway minted `channel.created` may carry `"auto":true` for a thread the gateway minted
itself for an incoming message (auto-threading): the name is an instant itself for an incoming message (auto-threading): the name is an instant
derived title, and a follow-up `channel.renamed` upgrades it to the model's derived title, and a follow-up `channel.renamed` upgrades it to the model's
title. title.
### `history` ### `history`
Response to a `history` request. Returns a page of messages for a chat/thread. Response to a `history` request. Returns a page of messages for a chat/thread.
```json ```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null, {"type":"history","id":20,"chat_id":"android:default","thread_id":null,
"payload":{ "payload":{
@@ -154,17 +196,20 @@ Response to a `history` request. Returns a page of messages for a chat/thread.
"oldest_message_id":"m_8990" "oldest_message_id":"m_8990"
}} }}
``` ```
`messages` are ordered oldest → newest. Paginate with `before_message_id` in the `messages` are ordered oldest → newest. Paginate with `before_message_id` in the
request. The app uses this to **populate the initial view** when a channel is request. The app uses this to **populate the initial view** when a channel is
opened (complements `sync`, which only replays undelivered outbox frames). opened (complements `sync`, which only replays undelivered outbox frames).
### `commands.catalog` ### `commands.catalog`
Request (app → server, empty payload) and response: the gateway's Request (app → server, empty payload) and response: the gateway's
slash-command catalog for the app's `/` drawer. Derived from hermes' central slash-command catalog for the app's `/` drawer. Derived from hermes' central
`COMMAND_REGISTRY` (the same source the gateway help and the Telegram command `COMMAND_REGISTRY` (the same source the gateway help and the Telegram command
menu use), restricted to commands available on gateway surfaces, plus menu use), restricted to commands available on gateway surfaces, plus
plugin-registered commands. The app fuzzy-matches the typed prefix plugin-registered commands. The app fuzzy-matches the typed prefix
client-side (no `commands.complete` round-trip). client-side (no `commands.complete` round-trip).
```json ```json
{"type":"commands.catalog","id":21,"payload":{ {"type":"commands.catalog","id":21,"payload":{
"commands":[ "commands":[
@@ -173,11 +218,14 @@ client-side (no `commands.complete` round-trip).
{"name":"/status","description":"Show session status","args_hint":"","category":"Info","aliases":[]} {"name":"/status","description":"Show session status","args_hint":"","category":"Info","aliases":[]}
]}} ]}}
``` ```
`name`/`aliases` carry the leading slash; `args_hint` is the registry's `name`/`aliases` carry the leading slash; `args_hint` is the registry's
argument placeholder (empty when the command takes none). argument placeholder (empty when the command takes none).
### `commands.complete` ### `commands.complete`
Response to a `commands.complete` request. Autocomplete matches for a typed prefix. Response to a `commands.complete` request. Autocomplete matches for a typed prefix.
```json ```json
{"type":"commands.complete","id":22,"payload":{ {"type":"commands.complete","id":22,"payload":{
"prefix":"/mod", "prefix":"/mod",
@@ -187,15 +235,19 @@ Response to a `commands.complete` request. Autocomplete matches for a typed pref
``` ```
### `agent.busy` / `agent.idle` ### `agent.busy` / `agent.idle`
Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`. Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`.
```json ```json
{"type":"agent.busy","chat_id":"android:default","thread_id":null, {"type":"agent.busy","chat_id":"android:default","thread_id":null,
"payload":{"reason":"processing"}} "payload":{"reason":"processing"}}
{"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}} {"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}}
``` ```
`reason` ∈ `processing | tool | waiting_input | cron`. `reason` ∈ `processing | tool | waiting_input | cron`.
### `search.results` ### `search.results`
```json ```json
{"type":"search.results","id":7,"payload":{ {"type":"search.results","id":7,"payload":{
"query":"deploy","scope":"all","hits":[ "query":"deploy","scope":"all","hits":[
@@ -204,43 +256,56 @@ Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`
``` ```
### `media.offer` ### `media.offer`
Agent-sent media is available; app pulls bytes. Agent-sent media is available; app pulls bytes.
```json ```json
{"type":"media.offer","chat_id":"…","payload":{ {"type":"media.offer","chat_id":"…","payload":{
"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,"filename":"clip.mp4"}} "media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,"filename":"clip.mp4"}}
``` ```
### `read.receipt` ### `read.receipt`
The gateway acknowledges that the agent has received and started processing The gateway acknowledges that the agent has received and started processing
the user's message. The app uses it to show ✓✓ on user bubbles. the user's message. The app uses it to show ✓✓ on user bubbles.
```json ```json
{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}} {"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}}
``` ```
Emitted to the originating connection when a `message.send` is accepted for Emitted to the originating connection when a `message.send` is accepted for
processing (at the moment it is handed to the agent), for user-originated processing (at the moment it is handed to the agent), for user-originated
messages only. messages only.
### `status` ### `status`
Gateway health state. Broadcast to all connected clients at startup Gateway health state. Broadcast to all connected clients at startup
(`state: "online"`); `restarting` / `degraded` are reserved for future use. (`state: "online"`); `restarting` / `degraded` are reserved for future use.
```json ```json
{"type":"status","payload":{"state":"online"}} {"type":"status","payload":{"state":"online"}}
``` ```
`state` ∈ `online | restarting | degraded`. `state` ∈ `online | restarting | degraded`.
### `error` ### `error`
```json ```json
{"type":"error","id":7,"payload":{"code":"not_found","message":"chat_id unknown"}} {"type":"error","id":7,"payload":{"code":"not_found","message":"chat_id unknown"}}
``` ```
`code` ∈ `auth | not_found | rate_limited | media_too_large | unsupported | internal`. `code` ∈ `auth | not_found | rate_limited | media_too_large | unsupported | internal`.
### `pong` ### `pong`
Keepalive reply to `ping`. Keepalive reply to `ping`.
## App → Server (requests / actions) ## App → Server (requests / actions)
### `hello` ### `hello`
First frame; auth + caps. First frame; auth + caps.
```json ```json
{"type":"hello","payload":{ {"type":"hello","payload":{
"token":"<ANDROID_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S", "token":"<ANDROID_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
@@ -249,12 +314,15 @@ First frame; auth + caps.
``` ```
### `message.send` ### `message.send`
Send text (or a `/slash-command`). Send text (or a `/slash-command`).
```json ```json
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null, {"type":"message.send","id":10,"chat_id":"android:default","thread_id":null,
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"], "payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
"auto_thread":false}} "auto_thread":false}}
``` ```
`media_refs` reference completed `media.upload`s to attach. `media_refs` reference completed `media.upload`s to attach.
`auto_thread` (optional, default false) asks the gateway to mint a fresh `auto_thread` (optional, default false) asks the gateway to mint a fresh
thread for the message (auto-threading, `06-channels-cron-search.md` §6.3): thread for the message (auto-threading, `06-channels-cron-search.md` §6.3):
@@ -264,7 +332,9 @@ that is not a slash command. The gateway then broadcasts
`thread_id`. `thread_id`.
### `media.upload.start` / (binary) / `media.upload.end` ### `media.upload.start` / (binary) / `media.upload.end`
See `07-media.md`. See `07-media.md`.
```json ```json
{"type":"media.upload.start","id":11,"payload":{ {"type":"media.upload.start","id":11,"payload":{
"media_ref":"mu_1","kind":"image","mime":"image/jpeg","size":204800,"filename":"a.jpg"}} "media_ref":"mu_1","kind":"image","mime":"image/jpeg","size":204800,"filename":"a.jpg"}}
@@ -273,26 +343,33 @@ See `07-media.md`.
``` ```
### `media.upload.ack` ### `media.upload.ack`
Server → App response to `media.upload.end`: the ref is cached and may now be Server → App response to `media.upload.end`: the ref is cached and may now be
referenced in a `message.send` `media_refs`. Failures use `error` frames instead. referenced in a `message.send` `media_refs`. Failures use `error` frames instead.
```json ```json
{"type":"media.upload.ack","id":11,"payload":{"ok":true,"media_ref":"mu_1"}} {"type":"media.upload.ack","id":11,"payload":{"ok":true,"media_ref":"mu_1"}}
``` ```
### `media.pull` ### `media.pull`
Request agent-sent media bytes. Request agent-sent media bytes.
```json ```json
{"type":"media.pull","id":12,"payload":{"media_id":"md_5"}} {"type":"media.pull","id":12,"payload":{"media_id":"md_5"}}
// server replies: binary frames, then {"type":"media.pull.end","id":12,"payload":{"ok":true}} // server replies: binary frames, then {"type":"media.pull.end","id":12,"payload":{"ok":true}}
``` ```
### `picker.select` ### `picker.select`
Answer an interactive picker. Answer an interactive picker.
```json ```json
{"type":"picker.select","id":13,"payload":{"picker_id":"pm_1","value":"local/qwen3-27b"}} {"type":"picker.select","id":13,"payload":{"picker_id":"pm_1","value":"local/qwen3-27b"}}
``` ```
### `channel.create` / `channel.rename` / `channel.set_default` / `channel.delete` ### `channel.create` / `channel.rename` / `channel.set_default` / `channel.delete`
```json ```json
{"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}} {"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}}
{"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}} {"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}}
@@ -300,79 +377,101 @@ Answer an interactive picker.
``` ```
### `search` ### `search`
```json ```json
{"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}} {"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}}
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}} {"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}}
``` ```
`scope` ∈ `all | chat`. `scope` ∈ `all | chat`.
### `read.receipt` ### `read.receipt`
App → server: "user has viewed this message." Server stores the read state and App → server: "user has viewed this message." Server stores the read state and
broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to
mark messages as read locally (✓✓ on user bubbles). mark messages as read locally (✓✓ on user bubbles).
```json ```json
{"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}} {"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}}
``` ```
### `history` ### `history`
Load a page of messages for a chat/thread (initial open, scroll-up pagination). Load a page of messages for a chat/thread (initial open, scroll-up pagination).
```json ```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null, {"type":"history","id":20,"chat_id":"android:default","thread_id":null,
"payload":{"before_message_id":"m_8990","limit":50}} "payload":{"before_message_id":"m_8990","limit":50}}
``` ```
`before_message_id` — return messages older than this (omit for newest page). `before_message_id` — return messages older than this (omit for newest page).
`limit` — max messages (default 50, max 200). `limit` — max messages (default 50, max 200).
### `message.delete` ### `message.delete`
Delete the given message(s) from a chat/thread. The server removes them from the Delete the given message(s) from a chat/thread. The server removes them from the
outbox (so `history`/`sync` no longer return them) and broadcasts outbox (so `history`/`sync` no longer return them) and broadcasts
`message.deleted` to every device. Idempotent: a message already gone (pruned by `message.deleted` to every device. Idempotent: a message already gone (pruned by
retention) still yields a `message.deleted` broadcast so live caches drop it. retention) still yields a `message.deleted` broadcast so live caches drop it.
```json ```json
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null, {"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}} "payload":{"message_ids":["m_9001","m_9002"]}}
``` ```
### `commands.catalog` ### `commands.catalog`
Fetch the full slash-command catalog (for the `Menü` bottom sheet). Fetch the full slash-command catalog (for the `Menü` bottom sheet).
```json ```json
{"type":"commands.catalog","id":21,"payload":{}} {"type":"commands.catalog","id":21,"payload":{}}
``` ```
### `commands.complete` ### `commands.complete`
Autocomplete for a typed `/prefix`. Autocomplete for a typed `/prefix`.
```json ```json
{"type":"commands.complete","id":22,"payload":{"prefix":"/mod"}} {"type":"commands.complete","id":22,"payload":{"prefix":"/mod"}}
``` ```
### `agent.stop` ### `agent.stop`
Stop the current agent turn (abort generation / tool execution). Stop the current agent turn (abort generation / tool execution).
```json ```json
{"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}} {"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}}
``` ```
### `agent.steer` ### `agent.steer`
Inject a steering message mid-turn (redirects the agent without a new turn). Inject a steering message mid-turn (redirects the agent without a new turn).
```json ```json
{"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null, {"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null,
"payload":{"text":"Actually, focus on the error case."}} "payload":{"text":"Actually, focus on the error case."}}
``` ```
### `sync` ### `sync`
Reconnect catch-up. Replays **undelivered outbox frames** (frames sent while Reconnect catch-up. Replays **undelivered outbox frames** (frames sent while
this device was offline). Does NOT load full history — use `history` for that. this device was offline). Does NOT load full history — use `history` for that.
```json ```json
{"type":"sync","id":19,"payload":{"cursor":1042}} {"type":"sync","id":19,"payload":{"cursor":1042}}
// server replays outbox frames with cursor > 1042, then {"type":"sync.done","id":19,"payload":{"cursor":1099}} // server replays outbox frames with cursor > 1042, then {"type":"sync.done","id":19,"payload":{"cursor":1099}}
``` ```
### `fcm.register` ### `fcm.register`
Update push token. Update push token.
```json ```json
{"type":"fcm.register","payload":{"fcm_token":"<new>","ntfy_topic":"<topic>"}} {"type":"fcm.register","payload":{"fcm_token":"<new>","ntfy_topic":"<topic>"}}
``` ```
### `ping` ### `ping`
Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`. Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`.
## Ordering & reliability ## Ordering & reliability
+5 -3
View File
@@ -38,12 +38,13 @@
"reply_to": { "type": "string" }, "reply_to": { "type": "string" },
"model": { "type": "string" }, "model": { "type": "string" },
"tokens": { "type": "integer" }, "tokens": { "type": "integer" },
"runtime": { "$ref": "#/definitions/runtime" },
"ts": { "type": "integer", "description": "epoch millis" } "ts": { "type": "integer", "description": "epoch millis" }
} }
}, },
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } }, "message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } }, "message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } }, "message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "runtime": { "$ref": "#/definitions/runtime" }, "ts": { "type": "integer" } } },
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } }, "message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } }, "commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } }, "tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
@@ -62,7 +63,7 @@
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } }, "error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
"pong": { "payload": { "ts": { "type": "integer" } } }, "pong": { "payload": { "ts": { "type": "integer" } } },
"sync.done": { "payload": { "cursor": { "type": "integer" } } }, "sync.done": { "payload": { "cursor": { "type": "integer" } } },
"history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } }, "history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "runtime": {"$ref":"#/definitions/runtime"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } },
"media.pull.end": { "payload": { "ok": { "type": "boolean" } } }, "media.pull.end": { "payload": { "ok": { "type": "boolean" } } },
"media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } }, "media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } },
"commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } } "commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } }
@@ -93,7 +94,8 @@
"definitions": { "definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] }, "kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } }, "channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } },
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } } "media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } },
"runtime": { "type": "object", "description": "Structured runtime-metadata footer (app-controlled display). The gateway ALWAYS sends it on final assistant messages; whether/what is shown is a per-app setting (Settings -> Runtime footer), NOT a hermes config. All keys optional; absent when the data is unavailable (e.g. local models have no cost).", "properties": { "model": {"type":"string","description":"Bare model id, vendor prefix dropped (gpt-5.4)."}, "context_pct": {"type":"integer","description":"Last-call context occupancy, 0-100."}, "cwd": {"type":"string","description":"Home-relative working dir (~)."}, "latency": {"type":"number","description":"Wall-clock turn duration, seconds."}, "cost": {"type":"number","description":"Turn cost, USD."} } }
}, },
"x-planned-frames": [ "x-planned-frames": [
{ "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." }, { "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." },
+150 -3
View File
@@ -317,6 +317,132 @@ def _tool_end_fields(tool_name: str) -> dict[str, Any]:
return fields return fields
# ---------------------------------------------------------------------------
# Runtime-metadata footer (post_api_request hook)
#
# The app renders a Telegram-style footer under final assistant messages
# (model, context %, cwd, latency, cost). Display is controlled by the APP
# (Settings → Runtime footer), not hermes config — so the gateway ALWAYS
# sends the data. hermes core only appends its own *text* footer when
# ``display.runtime_footer.enabled`` is set, and the adapter has no access to
# the gateway's ``agent_result``, so we capture the same facts ourselves via
# the ``post_api_request`` plugin hook (fires after every provider call with
# model + usage):
#
# * model — the turn's latest model (failover-aware)
# * prompt_tokens — the latest call's prompt size (context occupancy)
# * 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.
# ---------------------------------------------------------------------------
_runtime_meta: dict[str, Any] = {}
_runtime_meta_lock = threading.Lock()
# Per-model context-window cache. Resolution may probe endpoints on first use
# (slow); the cache is process-lifetime so each model resolves at most once.
_context_length_cache: dict[str, int] = {}
# Upper bound (seconds) on context-window resolution during a final send, so a
# slow first-use probe never delays the reply. The worker thread keeps running
# and populates the cache, so the next turn is fast.
_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":
return
model = kwargs.get("model") or ""
usage = kwargs.get("usage") or {}
prompt_tokens = usage.get("prompt_tokens") or 0
with _runtime_meta_lock:
if model:
_runtime_meta["model"] = model
if prompt_tokens:
_runtime_meta["prompt_tokens"] = prompt_tokens
if "turn_start" not in _runtime_meta:
_runtime_meta["turn_start"] = time.monotonic()
def _take_runtime_meta() -> dict[str, Any]:
"""Drain the captured turn metadata (turn boundary)."""
with _runtime_meta_lock:
meta = dict(_runtime_meta)
_runtime_meta.clear()
return meta
def _resolve_context_length(model: str) -> int | None:
"""Best-effort context window for *model* (cached; None on failure).
Runs in a worker thread (may probe endpoints on first use). The cache is
populated even if the caller's asyncio task times out, so subsequent
turns resolve instantly.
"""
if not model:
return None
cached = _context_length_cache.get(model)
if cached:
return cached
try:
from agent.model_metadata import get_model_context_length
ctx = get_model_context_length(model)
if ctx and ctx > 0:
_context_length_cache[model] = int(ctx)
return int(ctx)
except Exception:
logger.debug("android: context-length resolution failed for %s", model, exc_info=True)
return None
def _home_relative_cwd(cwd: str) -> str:
"""Collapse ``$HOME`` to ``~`` (matches hermes' runtime footer)."""
if not cwd:
return ""
try:
home = os.path.expanduser("~")
p = os.path.abspath(cwd)
if home and (p == home or p.startswith(home + os.sep)):
return "~" + p[len(home) :]
return p
except Exception:
return cwd
async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
"""Build the ``runtime`` footer object from captured turn metadata.
Called on every final send (the app decides what to show). Fields without
data are omitted. ``meta`` is the drained turn buffer (model,
prompt_tokens, turn_start).
"""
model = (meta.get("model") or "").rsplit("/", 1)[-1]
prompt_tokens = meta.get("prompt_tokens") or 0
context_pct = None
if prompt_tokens and model:
try:
ctx_len = await asyncio.wait_for(
asyncio.to_thread(_resolve_context_length, model),
timeout=_CTX_RESOLVE_TIMEOUT_S,
)
except (asyncio.TimeoutError, Exception):
ctx_len = None
if ctx_len:
context_pct = round(prompt_tokens / ctx_len * 100)
turn_start = meta.get("turn_start")
latency = (time.monotonic() - turn_start) if turn_start else None
cwd = _home_relative_cwd(os.environ.get("TERMINAL_CWD", ""))
return protocol.runtime_footer(
model=model or None,
context_pct=context_pct,
cwd=cwd or None,
latency=latency,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Defaults # Defaults
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1159,7 +1285,7 @@ class AndroidAdapter(BasePlatformAdapter):
state = self._turn_state(chat_id) state = self._turn_state(chat_id)
# 1. Streaming segment start (stream consumer first send). # 1. Streaming segment start (stream consumer first send).
if meta.get("expect_edits") is True: if bool(meta.get("expect_edits")):
# A new content segment means the tool the model was waiting on # A new content segment means the tool the model was waiting on
# has returned -> close it before the segment opens. # has returned -> close it before the segment opens.
await self._close_open_tool(chat_id, state, thread_id) await self._close_open_tool(chat_id, state, thread_id)
@@ -1213,7 +1339,7 @@ class AndroidAdapter(BasePlatformAdapter):
return SendResult(success=True, message_id=message_id) return SendResult(success=True, message_id=message_id)
# 3. Final message (non-streaming final, or streaming fallback final). # 3. Final message (non-streaming final, or streaming fallback final).
if meta.get("notify") is True: if bool(meta.get("notify")):
reasoning, body = _split_reasoning(content) reasoning, body = _split_reasoning(content)
# Non-streaming: reasoning is prepended to content (split above). # Non-streaming: reasoning is prepended to content (split above).
# Streaming fallback: content has no reasoning, so use the # Streaming fallback: content has no reasoning, so use the
@@ -1224,6 +1350,11 @@ class AndroidAdapter(BasePlatformAdapter):
reasoning = _take_reasoning() or None reasoning = _take_reasoning() or None
else: else:
_reset_reasoning() _reset_reasoning()
# Runtime-metadata footer (app-controlled display): always attach
# the structured ``runtime`` object so the app can render its
# footer (Settings → Runtime footer). Independent of hermes'
# ``display.runtime_footer`` config.
runtime = await _build_runtime_footer(_take_runtime_meta())
if state.stream_id: if state.stream_id:
# Fallback final: close the open streaming segment in place. # Fallback final: close the open streaming segment in place.
message_id = state.stream_id message_id = state.stream_id
@@ -1236,6 +1367,7 @@ class AndroidAdapter(BasePlatformAdapter):
body, body,
reasoning=reasoning, reasoning=reasoning,
thread_id=thread_id, thread_id=thread_id,
runtime=runtime or None,
ts=int(time.time() * 1000), ts=int(time.time() * 1000),
), ),
) )
@@ -1251,6 +1383,7 @@ class AndroidAdapter(BasePlatformAdapter):
thread_id=thread_id, thread_id=thread_id,
reasoning=reasoning, reasoning=reasoning,
reply_to=reply_to, reply_to=reply_to,
runtime=runtime or None,
ts=int(time.time() * 1000), ts=int(time.time() * 1000),
), ),
) )
@@ -1310,6 +1443,8 @@ class AndroidAdapter(BasePlatformAdapter):
reasoning = _take_reasoning() or None reasoning = _take_reasoning() or None
else: else:
_reset_reasoning() _reset_reasoning()
# Runtime-metadata footer (app-controlled display).
runtime = await _build_runtime_footer(_take_runtime_meta())
state.stream_id = None state.stream_id = None
await self._broadcast_or_log( await self._broadcast_or_log(
chat_id, chat_id,
@@ -1319,6 +1454,7 @@ class AndroidAdapter(BasePlatformAdapter):
body, body,
reasoning=reasoning, reasoning=reasoning,
thread_id=thread_id, thread_id=thread_id,
runtime=runtime or None,
ts=int(time.time() * 1000), ts=int(time.time() * 1000),
), ),
) )
@@ -1344,6 +1480,7 @@ class AndroidAdapter(BasePlatformAdapter):
# Unknown id: treat as a streaming update (best effort). # Unknown id: treat as a streaming update (best effort).
if finalize: if finalize:
runtime = await _build_runtime_footer(_take_runtime_meta())
await self._broadcast_or_log( await self._broadcast_or_log(
chat_id, chat_id,
protocol.message_stop( protocol.message_stop(
@@ -1351,6 +1488,7 @@ class AndroidAdapter(BasePlatformAdapter):
message_id, message_id,
_strip_streaming_cursor(content), _strip_streaming_cursor(content),
thread_id=thread_id, thread_id=thread_id,
runtime=runtime or None,
ts=int(time.time() * 1000), ts=int(time.time() * 1000),
), ),
) )
@@ -1857,7 +1995,7 @@ class AndroidAdapter(BasePlatformAdapter):
# Skipped for slash commands (session-scoped, not conversation # Skipped for slash commands (session-scoped, not conversation
# starters) and replies (they continue where the user is). Threading # starters) and replies (they continue where the user is). Threading
# is only active on the default channel; other channels stay flat. # is only active on the default channel; other channels stay flat.
auto_thread = payload.get("auto_thread") is True auto_thread = bool(payload.get("auto_thread"))
default_entry = self._channels.default() default_entry = self._channels.default()
if ( if (
auto_thread auto_thread
@@ -2825,6 +2963,15 @@ def register(ctx):
ctx.register_hook("post_tool_call", _on_post_tool_call) ctx.register_hook("post_tool_call", _on_post_tool_call)
except Exception: except Exception:
logger.debug("android: post_tool_call hook registration failed", exc_info=True) logger.debug("android: 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
# footer); the gateway always sends the data (independent of hermes
# ``display.runtime_footer`` config).
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)
ctx.register_platform( ctx.register_platform(
name="android", name="android",
label="Android", label="Android",
+7 -7
View File
@@ -216,6 +216,7 @@ class Outbox:
"reasoning": payload.get("reasoning"), "reasoning": payload.get("reasoning"),
"model": payload.get("model"), "model": payload.get("model"),
"tokens": payload.get("tokens"), "tokens": payload.get("tokens"),
"runtime": payload.get("runtime"),
"ts": payload.get("ts"), "ts": payload.get("ts"),
"media": payload.get("media"), "media": payload.get("media"),
} }
@@ -230,6 +231,7 @@ class Outbox:
"reasoning": payload.get("reasoning"), "reasoning": payload.get("reasoning"),
"model": payload.get("model"), "model": payload.get("model"),
"tokens": payload.get("tokens"), "tokens": payload.get("tokens"),
"runtime": payload.get("runtime"),
"ts": payload.get("ts"), "ts": payload.get("ts"),
"media": None, "media": None,
} }
@@ -259,7 +261,7 @@ class Outbox:
# Omit absent optional fields (the app's serializer treats a # Omit absent optional fields (the app's serializer treats a
# missing key as its default, but a JSON ``null`` for a # missing key as its default, but a JSON ``null`` for a
# non-nullable field like ``media`` would fail to parse). # non-nullable field like ``media`` would fail to parse).
for key in ("reasoning", "model", "tokens", "ts", "media"): for key in ("reasoning", "model", "tokens", "runtime", "ts", "media"):
if m.get(key) is None: if m.get(key) is None:
m.pop(key, None) m.pop(key, None)
return { return {
@@ -310,12 +312,10 @@ class Outbox:
cursors.append(int(r["cursor"])) cursors.append(int(r["cursor"]))
if not cursors: if not cursors:
return 0 return 0
placeholders = ",".join("?" * len(cursors)) # One bound-parameter delete per cursor (a message spans only a few
sql = f"DELETE FROM outbox WHERE cursor IN ({placeholders})" # frames); same transaction, no string-built SQL.
# Safe: ``placeholders`` is only ``?`` markers; every cursor value is for cursor in cursors:
# bound as a parameter (no user data in the SQL text). self._conn.execute("DELETE FROM outbox WHERE cursor = ?", (cursor,))
# pi-lens-ignore: python-sql-injection
self._conn.execute(sql, cursors)
self._conn.commit() self._conn.commit()
return len(cursors) return len(cursors)
+57 -9
View File
@@ -249,6 +249,54 @@ def hello_ack(
) )
# ---------------------------------------------------------------------------
# Runtime-metadata footer (app-controlled display)
#
# The gateway ALWAYS attaches a structured ``runtime`` object to final
# assistant messages so the app can render a Telegram-style footer (model,
# context %, cwd, latency, cost). Whether/what is shown is a per-app setting,
# NOT a hermes config — the gateway sends the data unconditionally and the
# app decides. Mirrors hermes' ``gateway/runtime_footer.py`` fields but as
# structured data (the app formats + picks fields).
#
# Recognised keys (all optional; absent when the data is unavailable):
# model — bare model id, vendor prefix dropped (``gpt-5.4``)
# context_pct — last-call context occupancy, 0-100 (int)
# cwd — home-relative working dir (``~``)
# latency — wall-clock turn duration, seconds (float)
# cost — turn cost, USD (float); absent for local/free models
# ---------------------------------------------------------------------------
RUNTIME_FIELDS: tuple[str, ...] = ("model", "context_pct", "cwd", "latency", "cost")
def runtime_footer(
*,
model: str | None = None,
context_pct: int | None = None,
cwd: str | None = None,
latency: float | None = None,
cost: float | None = None,
) -> dict[str, Any]:
"""Build the structured ``runtime`` footer object.
Only fields with data are included (a partially-populated footer is
better than empty slots). Returns ``{}`` when nothing is available.
"""
d: dict[str, Any] = {}
if model:
d["model"] = model
if context_pct is not None:
d["context_pct"] = max(0, min(100, int(context_pct)))
if cwd:
d["cwd"] = cwd
if latency is not None and latency >= 0:
d["latency"] = round(latency, 3)
if cost is not None and cost > 0:
d["cost"] = round(cost, 6)
return d
# Frame builder mirrors the wire schema (docs/04); the many fields are the # Frame builder mirrors the wire schema (docs/04); the many fields are the
# message's full shape, so the arg count is intentional. # message's full shape, so the arg count is intentional.
def message( # noqa: PLR0913 def message( # noqa: PLR0913
@@ -263,6 +311,7 @@ def message( # noqa: PLR0913
reply_to: str | None = None, reply_to: str | None = None,
model: str | None = None, model: str | None = None,
tokens: int | None = None, tokens: int | None = None,
runtime: dict[str, Any] | None = None,
ts: int | None = None, ts: int | None = None,
) -> Frame: ) -> Frame:
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -280,11 +329,11 @@ def message( # noqa: PLR0913
payload["model"] = model payload["model"] = model
if tokens is not None: if tokens is not None:
payload["tokens"] = tokens payload["tokens"] = tokens
if runtime:
payload["runtime"] = runtime
if ts is not None: if ts is not None:
payload["ts"] = ts payload["ts"] = ts
return Frame( return Frame(type=TYPE_MESSAGE, chat_id=chat_id, thread_id=thread_id, payload=payload)
type=TYPE_MESSAGE, chat_id=chat_id, thread_id=thread_id, payload=payload
)
def typing(chat_id: str, on: bool = True, *, thread_id: str | None = None) -> Frame: def typing(chat_id: str, on: bool = True, *, thread_id: str | None = None) -> Frame:
@@ -342,6 +391,7 @@ def message_stop(
reasoning: str | None = None, reasoning: str | None = None,
model: str | None = None, model: str | None = None,
tokens: int | None = None, tokens: int | None = None,
runtime: dict[str, Any] | None = None,
ts: int | None = None, ts: int | None = None,
) -> Frame: ) -> Frame:
"""Finalize a streaming bubble.""" """Finalize a streaming bubble."""
@@ -355,6 +405,8 @@ def message_stop(
payload["model"] = model payload["model"] = model
if tokens is not None: if tokens is not None:
payload["tokens"] = tokens payload["tokens"] = tokens
if runtime:
payload["runtime"] = runtime
if ts is not None: if ts is not None:
payload["ts"] = ts payload["ts"] = ts
return Frame( return Frame(
@@ -648,9 +700,7 @@ def notification(
payload: dict[str, Any] = {"kind": kind, "title": title, "body": body} payload: dict[str, Any] = {"kind": kind, "title": title, "body": body}
if ts is not None: if ts is not None:
payload["ts"] = ts payload["ts"] = ts
return Frame( return Frame(type=TYPE_NOTIFICATION, chat_id=chat_id, thread_id=thread_id, payload=payload)
type=TYPE_NOTIFICATION, chat_id=chat_id, thread_id=thread_id, payload=payload
)
def fcm_register(fcm_token: str | None = None, ntfy_topic: str | None = None) -> Frame: def fcm_register(fcm_token: str | None = None, ntfy_topic: str | None = None) -> Frame:
@@ -713,9 +763,7 @@ def media_offer(
} }
if message_id: if message_id:
payload["message_id"] = message_id payload["message_id"] = message_id
return Frame( return Frame(type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload)
type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload
)
def media_pull_end(ok: bool, *, id: int | None = None) -> Frame: def media_pull_end(ok: bool, *, id: int | None = None) -> Frame: