Added slash command handling
This commit is contained in:
1 parent
10e9565e2e
commit
1bcadcf950
16 files changed
+566
-20
No files matched your search
@@ -23,6 +23,7 @@ import iris.protocol.mediaUploadStartFrame
|
|||||||
import iris.protocol.messageSendFrame
|
import iris.protocol.messageSendFrame
|
||||||
import iris.protocol.pingFrame
|
import iris.protocol.pingFrame
|
||||||
import iris.protocol.syncFrame
|
import iris.protocol.syncFrame
|
||||||
|
import iris.util.IrisLog
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -198,6 +199,7 @@ class GatewayClient(
|
|||||||
request,
|
request,
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
IrisLog.d("ws open (${response.code})")
|
||||||
webSocket.send(
|
webSocket.send(
|
||||||
helloFrame(
|
helloFrame(
|
||||||
token = token,
|
token = token,
|
||||||
@@ -256,10 +258,12 @@ class GatewayClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
IrisLog.w("ws closed code=$code reason=\"$reason\"")
|
||||||
closed.complete(Unit)
|
closed.complete(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
IrisLog.e("ws failure: ${t.javaClass.simpleName}: ${t.message} (http=${response?.code})")
|
||||||
fail.complete(t.message ?: "connection failed")
|
fail.complete(t.message ?: "connection failed")
|
||||||
closed.complete(Unit)
|
closed.complete(Unit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ const val TYPE_CHANNEL_DELETED = "channel.deleted"
|
|||||||
const val TYPE_CHANNEL_LIST = "channel.list"
|
const val TYPE_CHANNEL_LIST = "channel.list"
|
||||||
const val TYPE_SEARCH = "search"
|
const val TYPE_SEARCH = "search"
|
||||||
const val TYPE_SEARCH_RESULTS = "search.results"
|
const val TYPE_SEARCH_RESULTS = "search.results"
|
||||||
|
|
||||||
|
// Slash-command catalog (the composer's "/" drawer)
|
||||||
|
const val TYPE_COMMANDS_CATALOG = "commands.catalog"
|
||||||
const val TYPE_SYNC = "sync"
|
const val TYPE_SYNC = "sync"
|
||||||
const val TYPE_SYNC_DONE = "sync.done"
|
const val TYPE_SYNC_DONE = "sync.done"
|
||||||
const val TYPE_HISTORY = "history"
|
const val TYPE_HISTORY = "history"
|
||||||
@@ -145,6 +148,8 @@ data class ServerCaps(
|
|||||||
val tools: Boolean = false,
|
val tools: Boolean = false,
|
||||||
val media: Boolean = false,
|
val media: Boolean = false,
|
||||||
val search: Boolean = false,
|
val search: Boolean = false,
|
||||||
|
/** The gateway answers `commands.catalog` (the composer's "/" drawer). */
|
||||||
|
@SerialName("commands_catalog") val commandsCatalog: Boolean = false,
|
||||||
val push: String = "fcm",
|
val push: String = "fcm",
|
||||||
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
||||||
val pickers: Boolean = false,
|
val pickers: Boolean = false,
|
||||||
@@ -389,6 +394,24 @@ data class SearchResultsPayload(
|
|||||||
val hits: List<SearchHit> = emptyList(),
|
val hits: List<SearchHit> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── commands.catalog (slash-command catalog for the "/" drawer) ──────────
|
||||||
|
|
||||||
|
/** One slash command from the gateway catalog. [name]/[aliases] carry the
|
||||||
|
* leading slash ("/new", "/reset"); the app fuzzy-matches client-side. */
|
||||||
|
@Serializable
|
||||||
|
data class SlashCommand(
|
||||||
|
val name: String,
|
||||||
|
val description: String = "",
|
||||||
|
@SerialName("args_hint") val argsHint: String = "",
|
||||||
|
val category: String = "",
|
||||||
|
val aliases: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CommandsCatalogPayload(
|
||||||
|
val commands: List<SlashCommand> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -594,6 +617,11 @@ fun searchFrame(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Request the gateway's slash-command catalog (the composer's "/" drawer).
|
||||||
|
* Answered by a `commands.catalog` frame carrying the same id. */
|
||||||
|
fun commandsCatalogFrame(id: Int): Frame =
|
||||||
|
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,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import iris.platform.mediaCacheBaseDir
|
|||||||
import iris.platform.postSystemNotification
|
import iris.platform.postSystemNotification
|
||||||
import iris.protocol.ChannelDeletedPayload
|
import iris.protocol.ChannelDeletedPayload
|
||||||
import iris.protocol.ChannelInfo
|
import iris.protocol.ChannelInfo
|
||||||
|
import iris.protocol.CommandsCatalogPayload
|
||||||
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
|
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
|
||||||
import iris.protocol.HistoryMessage
|
import iris.protocol.HistoryMessage
|
||||||
import iris.protocol.HistoryPayload
|
import iris.protocol.HistoryPayload
|
||||||
@@ -27,6 +28,7 @@ import iris.protocol.ReadReceiptPayload
|
|||||||
import iris.protocol.ROLE_ASSISTANT
|
import iris.protocol.ROLE_ASSISTANT
|
||||||
import iris.protocol.SearchHit
|
import iris.protocol.SearchHit
|
||||||
import iris.protocol.SearchResultsPayload
|
import iris.protocol.SearchResultsPayload
|
||||||
|
import iris.protocol.SlashCommand
|
||||||
import iris.protocol.StatusPayload
|
import iris.protocol.StatusPayload
|
||||||
import iris.protocol.SyncDonePayload
|
import iris.protocol.SyncDonePayload
|
||||||
import iris.protocol.TYPE_ERROR
|
import iris.protocol.TYPE_ERROR
|
||||||
@@ -36,6 +38,7 @@ import iris.protocol.TYPE_CHANNEL_CREATED
|
|||||||
import iris.protocol.TYPE_CHANNEL_DELETED
|
import iris.protocol.TYPE_CHANNEL_DELETED
|
||||||
import iris.protocol.TYPE_CHANNEL_LIST
|
import iris.protocol.TYPE_CHANNEL_LIST
|
||||||
import iris.protocol.TYPE_CHANNEL_RENAMED
|
import iris.protocol.TYPE_CHANNEL_RENAMED
|
||||||
|
import iris.protocol.TYPE_COMMANDS_CATALOG
|
||||||
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
|
||||||
@@ -61,6 +64,7 @@ import iris.protocol.channelListFrame
|
|||||||
import iris.protocol.channelRenameFrame
|
import iris.protocol.channelRenameFrame
|
||||||
import iris.protocol.channelSetAutomationFrame
|
import iris.protocol.channelSetAutomationFrame
|
||||||
import iris.protocol.channelSetDefaultFrame
|
import iris.protocol.channelSetDefaultFrame
|
||||||
|
import iris.protocol.commandsCatalogFrame
|
||||||
import iris.protocol.historyFrame
|
import iris.protocol.historyFrame
|
||||||
import iris.protocol.messageDeleteFrame
|
import iris.protocol.messageDeleteFrame
|
||||||
import iris.protocol.searchFrame
|
import iris.protocol.searchFrame
|
||||||
@@ -231,6 +235,13 @@ class IrisController(
|
|||||||
private val _lastQuery = MutableStateFlow("")
|
private val _lastQuery = MutableStateFlow("")
|
||||||
val lastQuery: StateFlow<String> = _lastQuery.asStateFlow()
|
val lastQuery: StateFlow<String> = _lastQuery.asStateFlow()
|
||||||
|
|
||||||
|
// ── Slash-command catalog (the composer's "/" drawer) ─────────────────
|
||||||
|
// The gateway's slash commands (commands.catalog). Static per gateway
|
||||||
|
// run; requested on connect and lazily when the drawer opens with an
|
||||||
|
// empty cache. The app fuzzy-matches the typed prefix client-side.
|
||||||
|
private val _slashCommands = MutableStateFlow<List<SlashCommand>>(emptyList())
|
||||||
|
val slashCommands: StateFlow<List<SlashCommand>> = _slashCommands.asStateFlow()
|
||||||
|
|
||||||
// ── M4: media ─────────────────────────────────────────────────────────
|
// ── M4: media ─────────────────────────────────────────────────────────
|
||||||
private val mediaCache = MediaCache(mediaCacheBaseDir())
|
private val mediaCache = MediaCache(mediaCacheBaseDir())
|
||||||
|
|
||||||
@@ -397,6 +408,9 @@ class IrisController(
|
|||||||
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
|
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
|
||||||
_searching.value = false
|
_searching.value = false
|
||||||
}
|
}
|
||||||
|
TYPE_COMMANDS_CATALOG -> {
|
||||||
|
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
|
||||||
|
}
|
||||||
TYPE_SYNC_DONE -> {
|
TYPE_SYNC_DONE -> {
|
||||||
// Replayed frames already flowed through [events]; the
|
// Replayed frames already flowed through [events]; the
|
||||||
// cursor is authoritative server-side (outbox).
|
// cursor is authoritative server-side (outbox).
|
||||||
@@ -480,6 +494,9 @@ class IrisController(
|
|||||||
}
|
}
|
||||||
// M5: a deep link tapped before we were connected.
|
// M5: a deep link tapped before we were connected.
|
||||||
applyDeepLink()
|
applyDeepLink()
|
||||||
|
// Slash-command catalog for the composer's "/" drawer
|
||||||
|
// (static per gateway run; re-fetched on every (re)connect).
|
||||||
|
requestCommandsCatalog()
|
||||||
// Gateway came back after a restart -> announce it (hermes
|
// Gateway came back after a restart -> announce it (hermes
|
||||||
// routine, same icon + wording on all platforms). The core
|
// routine, same icon + wording on all platforms). The core
|
||||||
// does not send a startup/online notice to this platform, so
|
// does not send a startup/online notice to this platform, so
|
||||||
@@ -590,6 +607,15 @@ class IrisController(
|
|||||||
_searching.value = false
|
_searching.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Slash-command catalog (the composer's "/" drawer) ─────────────────
|
||||||
|
|
||||||
|
/** Request the gateway's slash-command catalog. No-op while disconnected
|
||||||
|
* (sendFrame drops silently); the response lands via [slashCommands]. */
|
||||||
|
fun requestCommandsCatalog() {
|
||||||
|
if (client.state.value !is GatewayClient.State.Connected) return
|
||||||
|
client.sendFrame(commandsCatalogFrame(0))
|
||||||
|
}
|
||||||
|
|
||||||
// ── M3: sync (reconnect catch-up) ─────────────────────────────────────
|
// ── M3: sync (reconnect catch-up) ─────────────────────────────────────
|
||||||
|
|
||||||
fun sync(cursor: Long) {
|
fun sync(cursor: Long) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import androidx.compose.animation.core.infiniteRepeatable
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -122,6 +124,7 @@ import iris.protocol.KIND_IMAGE
|
|||||||
import iris.protocol.ROLE_ASSISTANT
|
import iris.protocol.ROLE_ASSISTANT
|
||||||
import iris.protocol.ROLE_USER
|
import iris.protocol.ROLE_USER
|
||||||
import iris.protocol.SearchHit
|
import iris.protocol.SearchHit
|
||||||
|
import iris.protocol.SlashCommand
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
import iris.state.ToolDetail
|
import iris.state.ToolDetail
|
||||||
import iris.ui.MarkdownText
|
import iris.ui.MarkdownText
|
||||||
@@ -132,6 +135,7 @@ import iris.ui.theme.avatarColor
|
|||||||
import iris.ui.theme.contrastText
|
import iris.ui.theme.contrastText
|
||||||
import iris.util.formatDayLabel
|
import iris.util.formatDayLabel
|
||||||
import iris.util.formatTime
|
import iris.util.formatTime
|
||||||
|
import iris.util.fuzzyScore
|
||||||
import iris.util.localDayKey
|
import iris.util.localDayKey
|
||||||
import iris.util.prepareForMarkdown
|
import iris.util.prepareForMarkdown
|
||||||
import iris.util.preserveNewlinesAsHardBreaks
|
import iris.util.preserveNewlinesAsHardBreaks
|
||||||
@@ -256,6 +260,37 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
if (idx >= 0) railIndex = idx
|
if (idx >= 0) railIndex = idx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slash-command drawer (the composer's "/" autocomplete): while the input
|
||||||
|
// is a bare "/…" (no space yet), fuzzy-match the typed prefix against the
|
||||||
|
// gateway catalog. Tapping a row sends the command; the drawer closes
|
||||||
|
// when the input leaves the "/" form, the match list empties (unknown
|
||||||
|
// command), or a command is sent.
|
||||||
|
val slashCommands by controller.slashCommands.collectAsState()
|
||||||
|
val slashQuery =
|
||||||
|
if (input.startsWith("/") && !input.contains(" ")) input.removePrefix("/") else null
|
||||||
|
val slashMatches = remember(slashQuery, slashCommands) {
|
||||||
|
if (slashQuery == null) emptyList()
|
||||||
|
else slashCommands
|
||||||
|
.map { it to bestSlashScore(slashQuery, it) }
|
||||||
|
.filter { it.second >= 0 }
|
||||||
|
.sortedWith(compareBy({ it.second }, { it.first.name }))
|
||||||
|
.map { it.first }
|
||||||
|
}
|
||||||
|
val slashDrawerOpen = slashQuery != null && slashMatches.isNotEmpty()
|
||||||
|
|
||||||
|
// The catalog is fetched on connect; if the drawer opens before the first
|
||||||
|
// response lands (or the gateway predates the frame), fetch it lazily.
|
||||||
|
LaunchedEffect(slashQuery) {
|
||||||
|
if (slashQuery != null && slashCommands.isEmpty()) controller.requestCommandsCatalog()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSlashPick(cmd: SlashCommand) {
|
||||||
|
if (state !is GatewayClient.State.Connected) return
|
||||||
|
input = ""
|
||||||
|
controller.send(cmd.name)
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
}
|
||||||
|
|
||||||
// M6: keyboard shortcuts (docs/11 §11.3) — desktop only.
|
// M6: keyboard shortcuts (docs/11 §11.3) — desktop only.
|
||||||
val shortcutsModifier = if (isDesktop) {
|
val shortcutsModifier = if (isDesktop) {
|
||||||
Modifier.onPreviewKeyEvent { e ->
|
Modifier.onPreviewKeyEvent { e ->
|
||||||
@@ -272,6 +307,8 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
ctrlOrMeta && e.key == Key.F -> { showSearch = true; true }
|
ctrlOrMeta && e.key == Key.F -> { showSearch = true; true }
|
||||||
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
|
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
|
||||||
|
// Escape closes the slash drawer (clears the "/" input).
|
||||||
|
e.key == Key.Escape && slashDrawerOpen -> { input = ""; true }
|
||||||
e.key == Key.Escape && overlayOpen -> {
|
e.key == Key.Escape && overlayOpen -> {
|
||||||
showSearch = false
|
showSearch = false
|
||||||
showPicker = false
|
showPicker = false
|
||||||
@@ -550,6 +587,67 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slash-command drawer: rolls up over the composer while the input is a
|
||||||
|
// bare "/…". Fuzzy-matched against the gateway catalog; tapping a
|
||||||
|
// row sends the command (and closes the drawer).
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = slashDrawerOpen && !isAutomation && !selectionMode,
|
||||||
|
enter = fadeIn(tween(120)) + slideInVertically(tween(160)) { it / 3 },
|
||||||
|
exit = fadeOut(tween(100)) + slideOutVertically(tween(120)) { it / 3 },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(IrisColors.panel)
|
||||||
|
.border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)),
|
||||||
|
) {
|
||||||
|
slashMatches.take(SLASH_DRAWER_MAX).forEachIndexed { index, cmd ->
|
||||||
|
if (index > 0) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.background(IrisColors.divider),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSlashPick(cmd) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
cmd.name,
|
||||||
|
color = IrisColors.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
if (cmd.argsHint.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
" ${cmd.argsHint}",
|
||||||
|
color = IrisColors.textTertiary,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
cmd.description,
|
||||||
|
color = IrisColors.textSecondary,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Selection toolbar (replaces the composer while messages are selected).
|
// Selection toolbar (replaces the composer while messages are selected).
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
SelectionToolbar(
|
SelectionToolbar(
|
||||||
@@ -2430,3 +2528,19 @@ internal val ToolDetail.label: String
|
|||||||
ToolDetail.TRUNCATED -> "truncated"
|
ToolDetail.TRUNCATED -> "truncated"
|
||||||
ToolDetail.NOTHING -> "nothing"
|
ToolDetail.NOTHING -> "nothing"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Slash-command drawer ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Max rows in the slash drawer (the rest is reachable by typing more). */
|
||||||
|
private const val SLASH_DRAWER_MAX = 8
|
||||||
|
|
||||||
|
/** Best fuzzy rank of [query] against a command's name or any of its
|
||||||
|
* aliases; -1 when nothing matches. */
|
||||||
|
private fun bestSlashScore(query: String, cmd: SlashCommand): Int {
|
||||||
|
var best = fuzzyScore(query, cmd.name.removePrefix("/"))
|
||||||
|
for (alias in cmd.aliases) {
|
||||||
|
val s = fuzzyScore(query, alias.removePrefix("/"))
|
||||||
|
if (s >= 0 && (best < 0 || s < best)) best = s
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package iris.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy subsequence scoring for the slash-command drawer.
|
||||||
|
*
|
||||||
|
* [fuzzyScore] returns a non-negative rank (lower = better) when every
|
||||||
|
* character of [query] appears in [target] in order (case-insensitive), or
|
||||||
|
* -1 when it does not. Prefix matches rank best (1); a consecutive run of
|
||||||
|
* matched characters ranks better than a gapped one, so "mod" beats "m...d"
|
||||||
|
* for "/model".
|
||||||
|
*/
|
||||||
|
fun fuzzyScore(query: String, target: String): Int {
|
||||||
|
val q = query.lowercase()
|
||||||
|
if (q.isEmpty()) return 0
|
||||||
|
val t = target.lowercase()
|
||||||
|
if (t.startsWith(q)) return 1
|
||||||
|
var qi = 0
|
||||||
|
var score = 0
|
||||||
|
var prevMatch = -2
|
||||||
|
for (i in t.indices) {
|
||||||
|
if (t[i] == q[qi]) {
|
||||||
|
score += if (prevMatch == i - 1) 1 else 4
|
||||||
|
prevMatch = i
|
||||||
|
qi++
|
||||||
|
if (qi == q.length) return score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package iris.util
|
||||||
|
|
||||||
|
/** Minimal logging. On Android, stdout goes to logcat; on desktop it goes to
|
||||||
|
* the console. Kept dependency-free (common stdlib only). */
|
||||||
|
object IrisLog {
|
||||||
|
private const val TAG = "Iris"
|
||||||
|
|
||||||
|
fun d(msg: String) = println("$TAG: $msg")
|
||||||
|
|
||||||
|
fun w(msg: String) = println("$TAG: $msg")
|
||||||
|
|
||||||
|
fun e(msg: String) = println("$TAG: $msg")
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package iris.util
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class FuzzyTest {
|
||||||
|
@Test
|
||||||
|
fun emptyQueryMatchesEverything() {
|
||||||
|
assertEquals(0, fuzzyScore("", "new"))
|
||||||
|
assertEquals(0, fuzzyScore("", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prefixMatchRanksBest() {
|
||||||
|
assertEquals(1, fuzzyScore("mod", "model"))
|
||||||
|
assertEquals(1, fuzzyScore("NEW", "new"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subsequenceMatchesInOrder() {
|
||||||
|
assertTrue(fuzzyScore("nw", "new") >= 0)
|
||||||
|
assertTrue(fuzzyScore("mdl", "model") >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun outOfOrderDoesNotMatch() {
|
||||||
|
assertEquals(-1, fuzzyScore("wn", "new"))
|
||||||
|
assertEquals(-1, fuzzyScore("xyz", "model"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun consecutiveRunBeatsGapped() {
|
||||||
|
// "sta" is a consecutive run in "status"; "stu" is gapped in "status".
|
||||||
|
assertTrue(fuzzyScore("sta", "status") < fuzzyScore("stu", "status"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prefixBeatsSubsequence() {
|
||||||
|
assertTrue(fuzzyScore("mod", "model") < fuzzyScore("mdl", "model"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,18 +150,22 @@ 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`
|
||||||
Response to a `commands.catalog` request. Full slash-command list.
|
Request (app → server, empty payload) and response: the gateway's
|
||||||
|
slash-command catalog for the app's `/` drawer. Derived from hermes' central
|
||||||
|
`COMMAND_REGISTRY` (the same source the gateway help and the Telegram command
|
||||||
|
menu use), restricted to commands available on gateway surfaces, plus
|
||||||
|
plugin-registered commands. The app fuzzy-matches the typed prefix
|
||||||
|
client-side (no `commands.complete` round-trip).
|
||||||
```json
|
```json
|
||||||
{"type":"commands.catalog","id":21,"payload":{
|
{"type":"commands.catalog","id":21,"payload":{
|
||||||
"commands":[
|
"commands":[
|
||||||
{"name":"/new","description":"Start a new session","args_hint":"","category":"session"},
|
{"name":"/new","description":"Start a new session (fresh session ID + history)","args_hint":"[name]","category":"Session","aliases":["/reset"]},
|
||||||
{"name":"/model","description":"Switch model","args_hint":"<provider/model>","category":"config"},
|
{"name":"/model","description":"Switch model","args_hint":"<provider/model>","category":"Configuration","aliases":[]},
|
||||||
{"name":"/reasoning","description":"Toggle reasoning effort","args_hint":"[low|medium|high]","category":"config"},
|
{"name":"/status","description":"Show session status","args_hint":"","category":"Info","aliases":[]}
|
||||||
{"name":"/status","description":"Show session status","args_hint":"","category":"info"},
|
|
||||||
{"name":"/cron","description":"Manage cron jobs","args_hint":"<list|add|rm>","category":"automation"},
|
|
||||||
{"name":"/tools","description":"List available tools","args_hint":"","category":"info"}
|
|
||||||
]}}
|
]}}
|
||||||
```
|
```
|
||||||
|
`name`/`aliases` carry the leading slash; `args_hint` is the registry's
|
||||||
|
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.
|
||||||
|
|||||||
+17
-9
@@ -81,17 +81,25 @@ app/shared/src/
|
|||||||
auto-grow field, attach (paperclip), mic/send (right). Send on Enter
|
auto-grow field, attach (paperclip), mic/send (right). Send on Enter
|
||||||
(configurable: Enter=send vs Enter=newline).
|
(configurable: Enter=send vs Enter=newline).
|
||||||
|
|
||||||
### Menu button → all slash commands
|
### Slash commands
|
||||||
- `Menü` opens a **bottom sheet** listing the command catalog. The catalog is
|
- **`/` drawer (implemented):** typing `/` in the composer rolls a drawer up
|
||||||
served by the gateway via `commands.catalog` request → response with
|
over the input listing the command catalog. The catalog is served by the
|
||||||
`{name, description, args_hint, category}` per command, so it always matches
|
gateway via `commands.catalog` request → response with
|
||||||
hermes (`/new`, `/model`, `/reasoning`, `/status`, `/cron`, `/tools`, …).
|
`{name, description, args_hint, category, aliases}` per command (derived
|
||||||
- Typing `/` in the field shows **autocomplete** via `commands.complete`
|
from hermes' `COMMAND_REGISTRY`, gateway-available subset + plugin commands),
|
||||||
request (gateway matches the typed prefix). Selecting inserts `/cmd `.
|
so it always matches hermes (`/new`, `/model`, `/reasoning`, `/status`,
|
||||||
- Selecting a command sends `message.send {text:"/cmd args"}`.
|
`/cron`, …). The drawer is shown while the input is a bare `/…` (no space
|
||||||
|
yet); typing fuzzy-filters it **client-side** (subsequence match over name +
|
||||||
|
aliases — no `commands.complete` round-trip). Tapping a row sends
|
||||||
|
`message.send {text:"/cmd"}` and closes the drawer; an unknown command
|
||||||
|
empties the match list and closes it (the raw text can still be sent —
|
||||||
|
hermes answers with its unknown-command reply). Escape (desktop) clears the
|
||||||
|
`/` input.
|
||||||
|
- **Menu button (planned):** `Menü` opens a **bottom sheet** listing the same
|
||||||
|
catalog for discovery without typing.
|
||||||
- **Interactive commands** (`/model`, `/reasoning`, `/fast`, approvals,
|
- **Interactive commands** (`/model`, `/reasoning`, `/fast`, approvals,
|
||||||
clarifies) render as **native pickers** from `picker.*` frames (a dialog /
|
clarifies) render as **native pickers** from `picker.*` frames (a dialog /
|
||||||
sheet with the options; answer via `picker.select`).
|
sheet with the options; answer via `picker.select`) — planned.
|
||||||
|
|
||||||
### Streaming (app-controlled)
|
### Streaming (app-controlled)
|
||||||
- **Settings → "Streaming"** toggle (default on). When off, the app ignores
|
- **Settings → "Streaming"** toggle (default on). When off, the app ignores
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ without the app (critical for verifying frame shapes early).
|
|||||||
carry the new `thread_id`; no-op with an existing `thread_id`, for slash
|
carry the new `thread_id`; no-op with an existing `thread_id`, for slash
|
||||||
commands, or for media-only sends; the LLM upgrade renames the thread
|
commands, or for media-only sends; the LLM upgrade renames the thread
|
||||||
(`channel.renamed`).
|
(`channel.renamed`).
|
||||||
|
- **Slash catalog:** `commands.catalog` request → response with the
|
||||||
|
gateway-available `COMMAND_REGISTRY` subset + plugin commands (each entry
|
||||||
|
`name`/`description`/`args_hint`/`category`/`aliases`); `cli_only` commands
|
||||||
|
excluded; response `id` matches the request `id`.
|
||||||
- **No `~/.hermes` writes in tests** — use the `_isolate_hermes_home` fixture
|
- **No `~/.hermes` writes in tests** — use the `_isolate_hermes_home` fixture
|
||||||
pattern (temp `HERMES_HOME`). Profile tests also mock `Path.home()`.
|
pattern (temp `HERMES_HOME`). Profile tests also mock `Path.home()`.
|
||||||
|
|
||||||
@@ -122,6 +126,11 @@ adb logcat -d > /tmp/logcat.txt
|
|||||||
flat lane → a new topic appears (derived name), the app jumps into it, the
|
flat lane → a new topic appears (derived name), the app jumps into it, the
|
||||||
reply streams there, and the topic is renamed to the AI's title a moment
|
reply streams there, and the topic is renamed to the AI's title a moment
|
||||||
later. Slash commands / media-only sends stay in the flat lane.
|
later. Slash commands / media-only sends stay in the flat lane.
|
||||||
|
14. **Slash drawer:** type `/` in the composer → the drawer rolls up over the
|
||||||
|
input with the gateway catalog; keep typing → fuzzy filtering (unrelated
|
||||||
|
entries drop out); tap a row → the command is sent and the drawer closes;
|
||||||
|
type an unknown command → the drawer closes (the raw text can still be
|
||||||
|
sent; hermes answers with its unknown-command reply).
|
||||||
|
|
||||||
## 13.5 Debugging tips
|
## 13.5 Debugging tips
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# 17 — Future Control Surface (post-M7)
|
||||||
|
|
||||||
|
> **Status: research / backlog — not scheduled.** M0–M7 cover the chat surface
|
||||||
|
> (messages, streaming, tools, media, push, channels, search). This doc records
|
||||||
|
> what the app could *additionally* control through the gateway, based on a
|
||||||
|
> survey of the hermes-agent source (2026-08-21). Nothing here is locked; it is
|
||||||
|
> a menu of options, each with the hermes backend that already implements it.
|
||||||
|
|
||||||
|
## The key insight
|
||||||
|
|
||||||
|
Hermes already ships a **complete REST API** — `hermes_cli/web_server.py` plus
|
||||||
|
`hermes_cli/web_routers/` — which is the backend of the desktop/dashboard UI.
|
||||||
|
Our gateway plugin runs **inside the same `hermes gateway` process**, so every
|
||||||
|
capability below is reachable by importing and calling the same functions the
|
||||||
|
web server calls. Exposing one from the app = a new request/response frame pair
|
||||||
|
in our protocol (`protocol.py` → `Protocol.kt` → `frames.schema.json`, the
|
||||||
|
usual three-way mirror).
|
||||||
|
|
||||||
|
The constraint is never "can hermes do it?" (it can — all of it) — it is how
|
||||||
|
many frame pairs we want to add and the security review each one needs.
|
||||||
|
|
||||||
|
## Implementation paths
|
||||||
|
|
||||||
|
1. **Direct function calls (preferred).** The plugin imports the hermes
|
||||||
|
modules (`cron.jobs`, `hermes_cli.kanban_db`, config loaders, …) and calls
|
||||||
|
them. No extra process, no extra port, zero new deps.
|
||||||
|
2. **Proxy to the web server's REST API.** Only works if `hermes web` /
|
||||||
|
`hermes dashboard` is running — it is not by default. Rejected for v1.
|
||||||
|
|
||||||
|
## Capability menu
|
||||||
|
|
||||||
|
### Cron jobs — fully controllable
|
||||||
|
|
||||||
|
| App action | Hermes backend |
|
||||||
|
|---|---|
|
||||||
|
| List jobs (incl. disabled) | `cron/jobs.py` `list_jobs()` |
|
||||||
|
| Create job | `cron/jobs.py` `create_job()` (schedules: `30m`, `every monday 9am`, 5-field cron, ISO one-shot) |
|
||||||
|
| Edit job | `cron/jobs.py` `update_job(job_id, updates)` |
|
||||||
|
| Pause / resume | `cron/jobs.py` `pause_job()` / `resume_job()` |
|
||||||
|
| Trigger now | `cron/jobs.py` `trigger_job()` |
|
||||||
|
| Delete job | web router `web_routers/cron.py` `DELETE /api/cron/jobs/{id}` |
|
||||||
|
| Run history | `web_routers/cron.py` `GET /api/cron/jobs/{id}/runs`; `hermes_state_portability.py` `list_cron_job_runs()` |
|
||||||
|
| Delivery targets | `web_routers/cron.py` `GET /api/cron/delivery-targets` |
|
||||||
|
| One-tap suggestions | `cron/suggestions.py` — ready-to-run job specs the user accepts/dismisses; `accept_suggestion()` calls `create_job` directly. **Designed for a UI; ideal first feature.** |
|
||||||
|
| Blueprints | `cron/blueprint_catalog.py`, `web_routers/cron.py` `GET /api/cron/blueprints` + `POST /api/cron/blueprints/instantiate` |
|
||||||
|
|
||||||
|
Per-job fields worth surfacing: `model`/`provider` overrides, `skills`,
|
||||||
|
`script`, `context_from` (chain job A's output into job B), `workdir`,
|
||||||
|
multi-platform delivery.
|
||||||
|
|
||||||
|
### Kanban board — fully controllable
|
||||||
|
|
||||||
|
| App action | Hermes backend |
|
||||||
|
|---|---|
|
||||||
|
| Boards: list / create / rename / delete / switch | `hermes_cli/kanban_db.py` `create_board()` / `list_boards()`; REST `plugins/kanban/dashboard/plugin_api.py` `/boards*` |
|
||||||
|
| Tasks: create / list / show / update / delete | `kanban_db.py` `create_task()` / `list_tasks()` / `complete_task()`; REST `/tasks*` |
|
||||||
|
| Comments / links / attachments | REST `/tasks/{id}/comments`, `/links`, `/tasks/{id}/attachments` |
|
||||||
|
| Bulk operations | REST `POST /tasks/bulk` |
|
||||||
|
| Dispatch / reassign / reclaim / estimate | REST `/dispatch`, `/tasks/{id}/reassign`, `/tasks/{id}/reclaim`, `/estimate` |
|
||||||
|
| Runs: inspect / terminate | REST `/runs/{run_id}`, `/runs/{run_id}/terminate` |
|
||||||
|
| Stats / diagnostics / active workers | REST `/stats`, `/diagnostics`, `/workers/active` |
|
||||||
|
| Per-task model options | REST `/model-options` |
|
||||||
|
|
||||||
|
Bonus: the kanban dispatcher already runs **inside the gateway** by default
|
||||||
|
(`kanban.dispatch_in_gateway: true`), so a phone app would see dispatch
|
||||||
|
activity live. The full REST surface (~30 endpoints) is in
|
||||||
|
`plugins/kanban/dashboard/plugin_api.py`.
|
||||||
|
|
||||||
|
### Model / provider registry — fully controllable
|
||||||
|
|
||||||
|
| App action | Hermes backend |
|
||||||
|
|---|---|
|
||||||
|
| Set active model | `web_server.py` `POST /api/model/set` |
|
||||||
|
| List model options / info / recommended default | `GET /api/model/options`, `/api/model/info`, `/api/model/recommended-default` |
|
||||||
|
| Auxiliary (side-LLM) models | `GET/PUT /api/model/auxiliary` |
|
||||||
|
| MoA config | `GET/PUT /api/model/moa` |
|
||||||
|
| Per-profile model | `web_routers/profiles.py` `PUT /api/profiles/{name}/model` |
|
||||||
|
| Per-toolset model / provider / env | `web_routers/tools.py` `PUT /api/tools/toolsets/{name}/model` / `/provider` / `/env` |
|
||||||
|
| Custom endpoints: CRUD + validate + activate | `web_server.py` `/api/providers/custom-endpoints*` |
|
||||||
|
| Provider validation / OAuth flows | `POST /api/providers/validate`, `/api/providers/oauth*` |
|
||||||
|
| API keys (`.env`): set / reveal / delete | `web_server.py` `GET/PUT/DELETE /api/env`, `POST /api/env/reveal` |
|
||||||
|
| `config.yaml`: read / write / schema | `GET/PUT /api/config`, `GET /api/config/schema` |
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
| App action | Hermes backend |
|
||||||
|
|---|---|
|
||||||
|
| Subscribe / list / remove / test inbound webhook routes | `hermes_cli/webhook.py` (`hermes webhook` CLI: `_cmd_subscribe`, `_cmd_list`, `_cmd_remove`, `_cmd_test`); subscriptions stored in `~/.hermes/webhooks/subscriptions.json` |
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
List, FTS search, rename, delete, bulk-delete, export (md/html), prune —
|
||||||
|
`web_routers/sessions.py` (`/api/sessions*`). Overlaps with what the app
|
||||||
|
already shows from its own chat; useful for browsing *other* platforms'
|
||||||
|
sessions (Telegram, CLI, cron runs).
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
CRUD, switch active profile, edit soul/description, per-profile model,
|
||||||
|
export/import — `web_routers/profiles.py` (`/api/profiles*`).
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
|
||||||
|
List, toggle, create/edit content, hub search/install/uninstall/update —
|
||||||
|
`web_routers/skills.py` (`/api/skills*`, `/api/skills/hub/*`).
|
||||||
|
|
||||||
|
### Tools / toolsets
|
||||||
|
|
||||||
|
Enable/disable per platform, per-toolset config/model/provider/env —
|
||||||
|
`web_routers/tools.py` (`/api/tools/toolsets*`).
|
||||||
|
|
||||||
|
### MCP servers
|
||||||
|
|
||||||
|
CRUD, test, auth/OAuth flows, catalog browse + install —
|
||||||
|
`web_routers/mcp.py` (`/api/mcp/*`).
|
||||||
|
|
||||||
|
### Git
|
||||||
|
|
||||||
|
Status, branches, worktrees, and a full review flow (stage/unstage/revert/
|
||||||
|
commit/push/create-PR) — `web_routers/git.py` (`/api/git/*`).
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
Upload/download/read/write, fs list/mkdir — `web_server.py` `/api/files*`,
|
||||||
|
`/api/fs/*`. (Complements the existing media frames in `07-media.md`.)
|
||||||
|
|
||||||
|
### Messaging platforms
|
||||||
|
|
||||||
|
List/configure/test other platforms (Telegram, WhatsApp onboarding) —
|
||||||
|
`web_server.py` `/api/messaging/*`.
|
||||||
|
|
||||||
|
### Memory providers
|
||||||
|
|
||||||
|
Config + setup per provider — `web_server.py` `/api/memory/providers/*`.
|
||||||
|
|
||||||
|
### Ops / system
|
||||||
|
|
||||||
|
| App action | Hermes backend |
|
||||||
|
|---|---|
|
||||||
|
| Gateway restart / drain | `web_server.py` `POST /api/gateway/restart`, `/api/gateway/drain` |
|
||||||
|
| Hermes update check / install | `POST /api/hermes/update`, `GET /api/hermes/update/check` |
|
||||||
|
| Curator (skill maintenance) status / pause / run | `GET /api/curator`, `PUT /api/curator/paused`, `POST /api/curator/run` |
|
||||||
|
| Audio: transcribe / TTS / voices | `POST /api/audio/transcribe`, `POST /api/audio/speak`, `GET /api/audio/elevenlabs/voices` |
|
||||||
|
| System stats / status / health | `GET /api/system/stats`, `/api/status`, `/api/health` |
|
||||||
|
| Learning graph node CRUD | `GET/PUT/DELETE /api/learning/node*` |
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
1. **Protocol cost is low.** The existing frame model (`type` + payload,
|
||||||
|
request/response correlated by `id`) already fits; we would add pairs like
|
||||||
|
`cron.list`/`cron.create`, `kanban.task.create`, `model.set`, following the
|
||||||
|
standard three-way mirror (`gateway-plugin/protocol.py` →
|
||||||
|
`app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`).
|
||||||
|
2. **Security is the real gate.** The single-user model in
|
||||||
|
`09-pairing-security.md` still holds, but control frames widen the blast
|
||||||
|
radius of a leaked `ANDROID_TOKEN`. Sensitive operations (env/secrets,
|
||||||
|
config writes, gateway restart, profile deletion) should get either an
|
||||||
|
in-app confirmation step or a capability flag negotiated at pairing.
|
||||||
|
3. **Read-heavy first.** Most of the value is in list/view frames (cheap,
|
||||||
|
low-risk); write frames can follow per domain.
|
||||||
|
4. **Broadcast routing applies.** Frames broadcast to all connected devices
|
||||||
|
(locked decision, `16-open-questions.md`); a control *response* should be
|
||||||
|
routed to the requesting device only, like other request/response pairs.
|
||||||
|
|
||||||
|
## Natural first picks (suggested order)
|
||||||
|
|
||||||
|
1. **Cron** — list / create / pause / trigger + one-tap suggestions (the
|
||||||
|
suggestion flow was literally designed for a UI).
|
||||||
|
2. **Kanban** — view board, add task, complete task.
|
||||||
|
3. **Model switch** — `model.set` + `model.options` (small, high value).
|
||||||
|
4. **Sessions** — list/search across platforms.
|
||||||
|
|
||||||
|
All four are read-heavy with cheap writes and low security risk.
|
||||||
@@ -38,6 +38,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
|||||||
| 14 | [`14-milestones.md`](14-milestones.md) | Planning work / tracking progress. |
|
| 14 | [`14-milestones.md`](14-milestones.md) | Planning work / tracking progress. |
|
||||||
| 15 | [`15-hermes-reference.md`](15-hermes-reference.md) | **Cheat-sheet** of hermes-agent source to read. |
|
| 15 | [`15-hermes-reference.md`](15-hermes-reference.md) | **Cheat-sheet** of hermes-agent source to read. |
|
||||||
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
|
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
|
||||||
|
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
|
||||||
|
|
||||||
Machine-readable / diagrams:
|
Machine-readable / diagrams:
|
||||||
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
|
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
"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"}, "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." } } } } } }
|
||||||
},
|
},
|
||||||
"app_to_server": {
|
"app_to_server": {
|
||||||
"hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
"hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
"channel.set_automation": { "description": "Mark a channel as automation (read-only for the user; it only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. The default channel cannot be marked (error not_found). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } },
|
"channel.set_automation": { "description": "Mark a channel as automation (read-only for the user; it only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. The default channel cannot be marked (error not_found). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } },
|
||||||
"channel.delete": { "payload": {} },
|
"channel.delete": { "payload": {} },
|
||||||
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} },
|
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} },
|
||||||
|
"commands.catalog": { "description": "Request the gateway's slash-command catalog (the app's '/' drawer); answered by the server_to_app commands.catalog frame carrying the same id.", "payload": {} },
|
||||||
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
|
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
|
||||||
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
|
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
|
||||||
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
|
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
|
||||||
@@ -98,9 +100,7 @@
|
|||||||
{ "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." },
|
{ "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." },
|
||||||
{ "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." },
|
{ "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." },
|
||||||
{ "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." },
|
{ "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." },
|
||||||
{ "name": "commands.catalog", "direction": "server_to_app", "note": "Slash-command catalog. Planned, not implemented." },
|
{ "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented (the app fuzzy-matches the commands.catalog list client-side)." },
|
||||||
{ "name": "commands.catalog", "direction": "app_to_server", "note": "Slash-command catalog request. Planned, not implemented." },
|
|
||||||
{ "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented." },
|
|
||||||
{ "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." },
|
{ "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." },
|
||||||
{ "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." },
|
{ "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." },
|
||||||
{ "name": "agent.idle", "direction": "server_to_app", "note": "Agent-idle indicator. Planned, not implemented." },
|
{ "name": "agent.idle", "direction": "server_to_app", "note": "Agent-idle indicator. Planned, not implemented." },
|
||||||
|
|||||||
@@ -126,6 +126,66 @@ from .pairing import ( # noqa: E402
|
|||||||
from .ws_server import WsServer # noqa: E402
|
from .ws_server import WsServer # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slash-command catalog (the app's "/" drawer)
|
||||||
|
#
|
||||||
|
# Derived from hermes' central ``COMMAND_REGISTRY`` (``hermes_cli/commands.py``)
|
||||||
|
# — the same source the gateway help text and the Telegram command menu use —
|
||||||
|
# restricted to commands available on gateway surfaces, plus plugin-registered
|
||||||
|
# commands. Never raises: any import/attribute problem (code skew between the
|
||||||
|
# plugin and the hermes checkout) degrades to an empty catalog, so the app's
|
||||||
|
# drawer simply stays closed.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _slash_command_catalog() -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
from hermes_cli import commands as hermes_commands
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"android: slash catalog unavailable (hermes_cli.commands import failed)",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _entry(name: str, description: str, args_hint: str, category: str,
|
||||||
|
aliases: List[str]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": f"/{name}",
|
||||||
|
"description": description,
|
||||||
|
"args_hint": args_hint or "",
|
||||||
|
"category": category,
|
||||||
|
"aliases": [f"/{a}" for a in aliases],
|
||||||
|
}
|
||||||
|
|
||||||
|
entries: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
overrides = hermes_commands._resolve_config_gates()
|
||||||
|
for cmd in hermes_commands.COMMAND_REGISTRY:
|
||||||
|
if not hermes_commands._is_gateway_available(cmd, overrides):
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category,
|
||||||
|
list(cmd.aliases))
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Code skew: the private helpers moved. Fall back to the plain
|
||||||
|
# cli_only filter (config-gated commands are dropped, acceptable).
|
||||||
|
logger.warning("android: slash catalog fell back to cli_only filter",
|
||||||
|
exc_info=True)
|
||||||
|
entries = [
|
||||||
|
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category,
|
||||||
|
list(cmd.aliases))
|
||||||
|
for cmd in hermes_commands.COMMAND_REGISTRY
|
||||||
|
if not cmd.cli_only
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for name, description, args_hint in hermes_commands._iter_plugin_command_entries():
|
||||||
|
entries.append(_entry(name, description, args_hint, "Plugin", []))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# M2 — reasoning capture (streaming)
|
# M2 — reasoning capture (streaming)
|
||||||
#
|
#
|
||||||
@@ -2244,6 +2304,17 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.send_to(device_id, resp)
|
await self._ws_server.send_to(device_id, resp)
|
||||||
|
|
||||||
|
# ── Slash-command catalog (app's "/" drawer) ──────────────────────────
|
||||||
|
|
||||||
|
async def on_commands_catalog(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Handle an inbound ``commands.catalog`` request: reply with the
|
||||||
|
gateway's slash-command catalog (hermes ``COMMAND_REGISTRY``,
|
||||||
|
gateway-available subset + plugin commands). The app fuzzy-matches
|
||||||
|
the typed prefix client-side; the catalog is static per gateway run,
|
||||||
|
so no caching is needed here."""
|
||||||
|
resp = protocol.commands_catalog(_slash_command_catalog(), id=frame.id)
|
||||||
|
await self._ws_server.send_to(device_id, resp)
|
||||||
|
|
||||||
# ── M3: search (app -> agent) ─────────────────────────────────────────
|
# ── M3: search (app -> agent) ─────────────────────────────────────────
|
||||||
|
|
||||||
async def on_search(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_search(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
@@ -2562,6 +2633,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
"tools": True, # M2: tool.start/progress/end
|
"tools": True, # M2: tool.start/progress/end
|
||||||
"media": True, # M4: media.upload/offer/pull
|
"media": True, # M4: media.upload/offer/pull
|
||||||
"search": True, # M3: search frame
|
"search": True, # M3: search frame
|
||||||
|
"commands_catalog": True, # commands.catalog frame (the "/" drawer)
|
||||||
"push": self.push_backend,
|
"push": self.push_backend,
|
||||||
# M5: ntfy server URL (app listener discovery; "" when not ntfy).
|
# M5: ntfy server URL (app listener discovery; "" when not ntfy).
|
||||||
"push_ntfy_server": (
|
"push_ntfy_server": (
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ TYPE_CHANNEL_LIST = "channel.list"
|
|||||||
TYPE_SEARCH = "search"
|
TYPE_SEARCH = "search"
|
||||||
TYPE_SEARCH_RESULTS = "search.results"
|
TYPE_SEARCH_RESULTS = "search.results"
|
||||||
|
|
||||||
|
# Slash-command catalog (app's "/" drawer)
|
||||||
|
TYPE_COMMANDS_CATALOG = "commands.catalog"
|
||||||
|
|
||||||
# Reconnect catch-up (M3 outbox; extended by M5 push)
|
# Reconnect catch-up (M3 outbox; extended by M5 push)
|
||||||
TYPE_SYNC = "sync"
|
TYPE_SYNC = "sync"
|
||||||
TYPE_SYNC_DONE = "sync.done"
|
TYPE_SYNC_DONE = "sync.done"
|
||||||
@@ -502,6 +505,24 @@ def search_results(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slash-command catalog frames
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def commands_catalog(commands: List[Dict[str, Any]], *, id: Optional[int] = None) -> Frame:
|
||||||
|
"""Response to a ``commands.catalog`` request: the gateway's slash-command
|
||||||
|
catalog for the app's ``/`` drawer.
|
||||||
|
|
||||||
|
Each entry: ``{name, description, args_hint, category, aliases}`` where
|
||||||
|
``name``/``aliases`` carry the leading slash (``"/new"``, ``["/reset"]``).
|
||||||
|
"""
|
||||||
|
return Frame(
|
||||||
|
type=TYPE_COMMANDS_CATALOG,
|
||||||
|
id=id,
|
||||||
|
payload={"commands": commands},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sync frames (M3 outbox)
|
# Sync frames (M3 outbox)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -380,6 +380,8 @@ class WsServer:
|
|||||||
await self._adapter.on_channel_delete(frame, device_id)
|
await self._adapter.on_channel_delete(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
||||||
await self._adapter.on_channel_list(frame, device_id)
|
await self._adapter.on_channel_list(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_COMMANDS_CATALOG:
|
||||||
|
await self._adapter.on_commands_catalog(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_SEARCH:
|
elif frame.type == protocol.TYPE_SEARCH:
|
||||||
await self._adapter.on_search(frame, device_id)
|
await self._adapter.on_search(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_SYNC:
|
elif frame.type == protocol.TYPE_SYNC:
|
||||||
|
|||||||
Reference in new issue
Block a user