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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-21 23:26:58 +00:00
parent 81fa81612b
commit 201aafb5bf
6 changed files with 485 additions and 0 deletions
@@ -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<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
composableFromEnd<Route.AgentConsole> { AgentConsoleScreen(accountViewModel, nav) }
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
@@ -469,6 +469,8 @@ sealed class Route {
@Serializable object EditNestsServers : Route()
@Serializable object AgentConsole : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@Serializable object EditPaymentTargets : Route()
@@ -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<AgentConsoleViewModel.PersonaCard>) {
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)
@@ -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<HexKey, AgentTurnMetricPayload?>()
private val refreshMutex = Mutex()
private val _metrics = MutableStateFlow(AgentFleetMetrics.EMPTY)
val metrics: StateFlow<AgentFleetMetrics> = _metrics.asStateFlow()
private val _personas = MutableStateFlow<List<PersonaCard>>(emptyList())
val personas: StateFlow<List<PersonaCard>> = _personas.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _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?,
)
}
@@ -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),
),
)
+2
View File
@@ -1278,6 +1278,7 @@
<string name="nest_create_submit">Start space</string>
<string name="nest_create_schedule_toggle">Schedule for later</string>
<string name="nest_create_when">Pick a start time</string>
<string name="agent_console_title">Agent console</string>
<string name="nests_servers_title">Nest servers</string>
<string name="nests_servers_explainer">Choose which MoQ host servers Amethyst publishes your nests to. The first entry is used by default when you start a new space.</string>
<string name="nests_servers_my_section">Your servers</string>
@@ -2128,6 +2129,7 @@
<string name="event_sync_search_keywords" translatable="false">negentropy, sync, reconcile, backfill</string>
<string name="import_follows_search_keywords" translatable="false">contacts, follows, follow list, import</string>
<string name="nests_servers_search_keywords" translatable="false">audio rooms, live, spaces, rooms</string>
<string name="agent_console_search_keywords" translatable="false">buzz, agents, personas, cost, tokens, fleet, ai, turn metrics</string>
<string name="profile_badges_search_keywords" translatable="false">badges, awards</string>
<string name="favorite_dvms_search_keywords" translatable="false">dvm, data vending machine, algo, algorithm, feeds</string>
<string name="reactions_search_keywords" translatable="false">emoji, like, reaction</string>