From 201aafb5bfbc4cce79872582b9ea806d794bede5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 23:26:58 +0000 Subject: [PATCH] feat(buzz): agent-owner console with cost + personas tabs Read-only owner dashboard over the AI-agent fleet. AgentConsoleViewModel fetches turn metrics (kind:44200, p=owner) and personas (kind:30175, authored by owner) from the Buzz-dialect relays plus the owner outbox set, decrypts the NIP-44 metric payloads with the owner signer (cached by event id), and derives fleet + per-agent totals via the pure AgentFleetAggregator. AgentConsoleScreen renders a Costs tab (fleet total, per-agent spend/tokens/ turns/sessions, estimated-total warning) and a Personas tab (display name, model, runtime, provider, system prompt). Wired as Route.AgentConsole with a settings-catalog entry under App settings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/buzz/AgentConsoleScreen.kt | 308 ++++++++++++++++++ .../loggedIn/buzz/AgentConsoleViewModel.kt | 170 ++++++++++ .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 2 + 6 files changed, 485 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 05d2a66b4a..972e9781c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.Boo import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen @@ -456,6 +457,7 @@ fun BuildNavigation( composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, nav) } + composableFromEnd { AgentConsoleScreen(accountViewModel, nav) } composableFromEndArgs { NestLobbyScreen(it.addressValue, accountViewModel, nav) } composableFromEnd { LongsScreen(accountViewModel, nav) } composableFromEnd { ArticlesScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index e63bb36ad4..ebd4eb5162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -469,6 +469,8 @@ sealed class Route { @Serializable object EditNestsServers : Route() + @Serializable object AgentConsole : Route() + @Serializable object EditFavoriteAlgoFeeds : Route() @Serializable object EditPaymentTargets : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt new file mode 100644 index 0000000000..53a185748e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics +import com.vitorpamplona.amethyst.commons.model.buzz.AgentUsageSummary +import com.vitorpamplona.amethyst.commons.model.buzz.TokenTotals +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * The workspace owner's agent-owner console — a read-only dashboard over the fleet of + * AI agents that publish to them. Two tabs: + * - **Costs** — fleet totals and a per-agent breakdown (turns, sessions, tokens, spend), + * derived from decrypted NIP-AM turn metrics (`kind:44200`). + * - **Personas** — the owner's published persona definitions (NIP-AP `kind:30175`). + * + * Data comes from [AgentConsoleViewModel], keyed by the owner pubkey so the fetch/decrypt + * work survives navigation. This v1 is read-only; persona editing and NIP-OA attestation + * issuance are follow-ups. + */ +@Composable +fun AgentConsoleScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val pubkey = accountViewModel.account.userProfile().pubkeyHex + val viewModel: AgentConsoleViewModel = viewModel(key = "AgentConsole-$pubkey") + + viewModel.bindAccountIfMissing(accountViewModel.account) + + val metrics by viewModel.metrics.collectAsStateWithLifecycle() + val personas by viewModel.personas.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + val tabs = remember { listOf("Costs", "Personas") } + + Scaffold( + topBar = { TopBarWithBackButton("Agent Console", nav) }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) }, + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + when (selectedTab) { + 0 -> CostsTab(metrics) + else -> PersonasTab(personas) + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp), + ) + } + } + } + } +} + +@Composable +private fun CostsTab(metrics: AgentFleetMetrics) { + if (metrics.agents.isEmpty()) { + EmptyState("No agent turn metrics yet. Metrics appear once your agents publish kind:44200 events to you.") + return + } + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + FleetSummaryCard(metrics) + } + items(metrics.agents) { agent -> + AgentCard(agent) + } + } +} + +@Composable +private fun FleetSummaryCard(metrics: AgentFleetMetrics) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "Fleet total", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = formatCost(metrics.totals.costUsd), + style = MaterialTheme.typography.headlineMedium, + ) + MetaRow("Agents", metrics.agents.size.toString()) + MetaRow("Sessions", metrics.totalSessions.toString()) + MetaRow("Turns", metrics.totalTurns.toString()) + TokenBreakdown(metrics.totals) + if (metrics.hasUnreliableEstimates) { + Text( + text = "⚠ Some totals are estimated from per-turn deltas (a session reported no cumulative counts).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@Composable +private fun AgentCard(agent: AgentUsageSummary) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = shortKey(agent.agentPubKey), + style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) + Text( + text = formatCost(agent.totals.costUsd), + style = MaterialTheme.typography.titleLarge, + ) + MetaRow("Sessions", agent.sessions.toString()) + MetaRow("Turns", agent.turns.toString()) + if (agent.models.isNotEmpty()) MetaRow("Models", agent.models.sorted().joinToString(", ")) + if (agent.harnesses.isNotEmpty()) MetaRow("Harness", agent.harnesses.sorted().joinToString(", ")) + agent.lastActivity?.let { MetaRow("Last turn", it) } + TokenBreakdown(agent.totals) + } + } +} + +@Composable +private fun TokenBreakdown(totals: TokenTotals) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + MetaRow("Input tokens", formatCount(totals.inputTokens)) + MetaRow("Output tokens", formatCount(totals.outputTokens)) + MetaRow("Total tokens", formatCount(totals.totalTokens)) + if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens)) + if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens)) +} + +@Composable +private fun PersonasTab(personas: List) { + if (personas.isEmpty()) { + EmptyState("No personas published. Personas you publish (kind:30175) appear here.") + return + } + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(personas) { persona -> + PersonaCardView(persona) + } + } +} + +@Composable +private fun PersonaCardView(persona: AgentConsoleViewModel.PersonaCard) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = persona.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = persona.slug, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + persona.model?.let { MetaRow("Model", it) } + persona.provider?.let { MetaRow("Provider", it) } + persona.runtime?.let { MetaRow("Runtime", it) } + persona.systemPrompt?.takeIf { it.isNotBlank() }?.let { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +private fun MetaRow( + label: String, + value: String, +) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun EmptyState(message: String) { + Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun formatCost(cost: Double): String = "$" + ((cost * 100).toLong() / 100.0).toString().let { padCents(it) } + +/** Pads a dollar string to exactly two decimals (e.g. "1.5" -> "1.50", "3" -> "3.00"). */ +private fun padCents(value: String): String { + val dot = value.indexOf('.') + if (dot < 0) return "$value.00" + val decimals = value.length - dot - 1 + return when { + decimals >= 2 -> value.substring(0, dot + 3) + decimals == 1 -> value + "0" + else -> value + "00" + } +} + +/** Groups a token count with thin spaces every three digits (1234567 -> "1 234 567"). */ +private fun formatCount(n: Long): String { + val s = n.toString() + if (s.length <= 3) return s + val sb = StringBuilder() + val firstGroup = s.length % 3 + if (firstGroup > 0) { + sb.append(s, 0, firstGroup) + } + var i = firstGroup + while (i < s.length) { + if (sb.isNotEmpty()) sb.append(' ') + sb.append(s, i, i + 3) + i += 3 + } + return sb.toString() +} + +private fun shortKey(hex: String): String = if (hex.length <= 16) hex else hex.take(8) + "…" + hex.takeLast(8) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt new file mode 100644 index 0000000000..3a85b073ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator +import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.filter +import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent +import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricPayload +import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Backing ViewModel for the [AgentConsoleScreen] — the workspace owner's read-only + * dashboard over their AI-agent fleet. + * + * It reads two Buzz kinds the owner authored or received: + * - **Personas** ([PersonaEvent], `kind:30175`) — plaintext, authored by the owner. + * - **Turn metrics** ([AgentTurnMetricEvent], `kind:44200`) — NIP-44 ciphertext an + * agent published to the owner (`p` tag = owner). Either party can decrypt; here the + * owner's [Account.signer] does. Decryption is cached by event id so a refresh that + * re-reads the cache never re-runs NIP-44 on an event it already opened. + * + * [refresh] fetches both kinds from the Buzz-dialect relays plus the owner's outbox set, + * lets [LocalCache] consume them, then re-derives the aggregate via the pure + * [AgentFleetAggregator] (which owns the cost/token semantics). The ViewModel is keyed by + * the owner pubkey in the screen, so one instance survives navigation in and out. + */ +class AgentConsoleViewModel : ViewModel() { + @Volatile private var account: Account? = null + + /** event id -> decrypted payload (null = decryption failed; don't retry). */ + private val decryptCache = HashMap() + private val refreshMutex = Mutex() + + private val _metrics = MutableStateFlow(AgentFleetMetrics.EMPTY) + val metrics: StateFlow = _metrics.asStateFlow() + + private val _personas = MutableStateFlow>(emptyList()) + val personas: StateFlow> = _personas.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + fun bindAccountIfMissing(account: Account) { + if (this.account != null) return + this.account = account + refresh() + } + + fun refresh() { + val account = account ?: return + viewModelScope.launch(Dispatchers.IO) { + refreshMutex.withLock { + _isLoading.value = true + try { + fetchFromRelays(account) + reloadFromCache(account) + } finally { + _isLoading.value = false + } + } + } + } + + /** + * One-shot paged fetch of the owner's personas (authored by owner) and turn metrics + * (addressed to the owner via the `p` tag) from every Buzz-dialect relay and the + * owner's own outbox relays. Events land in [LocalCache] via the normal consume path; + * this only primes the cache before [reloadFromCache] reads it back. + */ + private suspend fun fetchFromRelays(account: Account) { + val myPubkey = account.userProfile().pubkeyHex + val relays = BuzzRelayDialect.flow.value + account.outboxRelays.flow.value + if (relays.isEmpty()) return + + val filters = + listOf( + Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))), + Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(myPubkey)), + ) + + account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> } + } + + private suspend fun reloadFromCache(account: Account) { + val myPubkey = account.userProfile().pubkeyHex + val signer = account.signer + + _personas.value = + LocalCache.addressables + .filter(PersonaEvent.KIND, myPubkey) { _, note -> note.event is PersonaEvent } + .mapNotNull { note -> + val event = note.event as? PersonaEvent ?: return@mapNotNull null + val content = event.personaOrNull() + PersonaCard( + slug = event.slug() ?: "", + displayName = content?.displayName ?: event.slug() ?: "", + model = content?.model, + runtime = content?.runtime, + provider = content?.provider, + systemPrompt = content?.systemPrompt, + ) + }.sortedBy { it.displayName.lowercase() } + + val metricNotes = + LocalCache.filter( + Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))), + ) + + val decrypted = + metricNotes.mapNotNull { note -> + val event = note.event as? AgentTurnMetricEvent ?: return@mapNotNull null + val payload = + if (decryptCache.containsKey(event.id)) { + decryptCache[event.id] + } else { + event.decryptOrNull(signer).also { decryptCache[event.id] = it } + } ?: return@mapNotNull null + val agent = event.agentPubKey() ?: event.pubKey + agent to payload + } + + _metrics.value = AgentFleetAggregator.aggregate(decrypted) + } + + /** A persona rendered on the Personas tab; a flattened projection of [PersonaEvent]. */ + @Immutable + data class PersonaCard( + val slug: String, + val displayName: String, + val model: String?, + val runtime: String?, + val provider: String?, + val systemPrompt: String?, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index b63e323eef..d8ea10d119 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -104,6 +104,7 @@ fun buildSettingsCatalog( symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings), symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings), symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage), + symEntry(R.string.agent_console_title, MaterialSymbols.AutoAwesome, R.string.agent_console_search_keywords, Route.AgentConsole), ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a19d6b522c..8ffb313fe3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1278,6 +1278,7 @@ Start space Schedule for later Pick a start time + Agent console Nest servers Choose which MoQ host servers Amethyst publishes your nests to. The first entry is used by default when you start a new space. Your servers @@ -2128,6 +2129,7 @@ negentropy, sync, reconcile, backfill contacts, follows, follow list, import audio rooms, live, spaces, rooms + buzz, agents, personas, cost, tokens, fleet, ai, turn metrics badges, awards dvm, data vending machine, algo, algorithm, feeds emoji, like, reaction