Compare commits

...

15 Commits

Author SHA1 Message Date
Claude
12aa9fe954 feat(cli): first-class 'amy buzz' commands for block/buzz workspaces
Thin assembly over quartz + commons (no new protocol in cli/):
- buzz post RELAY GID <text>  — publish a kind-40002 stream message (h-scoped)
- buzz read RELAY GID         — drain the recent human-visible timeline (9/40002/40099)
- buzz attest AGENT           — sign a NIP-OA OwnerAttestation offline, print the auth tag
- buzz console [--relays]     — drain kind-44200 turn metrics (#p=me), NIP-44-decrypt,
                                and aggregate via the shared commons AgentFleetAggregator
- buzz personas [--relays]    — list my kind-30175 personas (newest per slug)

Join/leave/create reuse 'amy relaygroup' (Buzz workspaces are NIP-29 groups). Wired
into Main dispatch + usage; README command table + ROADMAP updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 05:01:25 +00:00
Claude
54364731a6 feat(buzz): 'Workspaces' bottom-nav tab + hub screen
Gives Buzz a first-class navigation identity instead of hiding inside the generic
Relay Groups list. NavBarItem.BUZZ (Route.BuzzWorkspaces) is a pinnable bottom-nav
destination (added to NavBarCatalog + BottomBarCategories + the preloader when).

BuzzWorkspacesScreen is a modern hub: an Agent-Console hero card up top, then the
user's joined groups filtered to Buzz-dialect relays as cards with a colored
monogram avatar, live name/host/member-count and an unread dot, plus an inviting
empty state that routes to Browse groups. It reuses the always-on relay-group state
subscription (no per-screen fetch) and the same RelayGroupChannel the chat opens.

Also fixes the open-chat-tail test for the added 20002 typing kind
(RELAY_GROUP_OPEN_TAIL_KINDS), and documents typing + the hub in the buzz README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 04:27:59 +00:00
Claude
a0d50c69f6 feat(buzz): live typing indicators (kind 20002)
Brings Buzz to typing-indicator parity with Concord, verified against buzz-core
kind.rs (KIND_TYPING_INDICATOR=20002, requires_h_channel_scope=false).

- commons BuzzTypingState: process-wide, lock-guarded channel->typist->heartbeat
  registry with stale pruning + future-clamp (6 unit tests).
- LocalCache records 20002 heartbeats into it (still no feed row; own typing
  filtered in the UI).
- RELAY_GROUP_OPEN_TAIL_KINDS requests 20002 on the open channel's live tail only
  (ephemeral, scoped to the room on screen, never the joined fleet).
- Account.sendBuzzTyping fires a throttled heartbeat to the host relay; the
  composer sends it on text change (gated to Buzz relays).
- BuzzTypingIndicator: an animated three-dot '… is typing' row above the composer
  that slides in/out and ages typists out on a timer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 04:15:50 +00:00
Claude
5d9efc800a docs(buzz): correct reaction/delete claim — Buzz ALL_KINDS accepts 7/5/9005/1984/emoji
Earlier text wrongly said Buzz has no reaction kind. buzz-core/src/kind.rs ALL_KINDS
includes KIND_REACTION(7), KIND_DELETION(5), KIND_NIP29_DELETE_EVENT(9005)/GROUP(9008),
KIND_REPORT(1984), and emoji list/set (10030/30030); requires_h_channel_scope(7) is
false. So the shared chat action sheet's react/delete/report already work against Buzz.
Only zaps (no 9734/9735) aren't relay-stored (payment still works). Typing/presence are
accepted by Buzz but not yet rendered by Amethyst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 03:57:51 +00:00
Claude
f869834e9d docs(buzz): sync README with canvas viewer + edit composer; note no reaction kind
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:26:43 +00:00
Claude
fb7bdf3057 feat(buzz): edit composer for stream messages (kind 40003)
Closes the edit loop: Amethyst already renders 40003 edit overlays but could
not create them. ChannelNewMessageViewModel gains a Buzz edit mode
(editBuzzMessage/clearBuzzEdit) — the next send publishes a kind-40003 edit
targeting the original instead of a new 40002, kept minimal to mirror Buzz's
build_edit (h + e + content). An 'Edit' action on the message sheet is gated to
my own 40002 messages (the kind alone marks it Buzz) and threads to the composer
via an optional onWantsToEditBuzz callback through the shared chat feed
(default-null, so no other chat surface is affected). EditFieldRow shows an
editing banner with a cancel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:20:34 +00:00
Claude
f31e7a4c0b feat(buzz): workspace canvas viewer (kind 40100)
Adds a read surface for the Buzz canvas, which was consumed into overlay state
but had no UI. BuzzWorkspaceState gains a canvasUpdates flow; BuzzCanvasScreen
(Route.BuzzCanvas) renders the newest 40100 markdown for a channel and recomposes
when a newer revision lands. Entry point is a Dashboard icon in the relay-group
top bar, shown only on a Buzz-dialect relay once a canvas has arrived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:12:17 +00:00
Claude
4a4baf6a23 feat(buzz): NIP-OA agent auth at connect (hold + inject auth tag)
Tier 1 connect path. BuzzHeldAttestations (commons) stores the OwnerAttestations
this device received, keyed by the agent pubkey each authorizes (verify-passing
only). AuthCoordinator.buzzAugmented appends the owner-signed auth tag to an
account's NIP-42 AUTH event when that account's key has a held attestation and
the relay speaks the Buzz dialect — and only that account's AUTH, never the
Concord stream-key AUTHs sharing the template, and never on non-Buzz relays.
So an un-enrolled agent key gets virtual membership while its owner stays a member.

AgentAttestationScreen gains a 'Hold an attestation' section (paste the auth tag
JSON, verified against the current account, stored/removed) alongside the existing
owner-side issuance. Store is in-memory for now — persisting per-account is a
follow-up. 4 store tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:34:56 +00:00
Claude
bce544e8c3 feat(buzz): persona create/edit from the agent console
Tier 3 write flow. AgentPersonaEditScreen + AgentPersonaEditViewModel publish
a Buzz Agent Persona (NIP-AP kind:30175) to the workspace's Buzz-dialect relays
(falling back to the owner outbox). The Personas tab gains a 'New persona' row
and each card is tappable to edit.

On edit the existing PersonaEvent is loaded from LocalCache so fields the form
does not expose (name pool, respond-to allowlist, parallelism) are preserved
rather than dropped on republish; the slug (d tag) is locked once chosen and
validated against the relay's grammar (^[a-z0-9][a-z0-9_-]{0,63}$).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:28:30 +00:00
Claude
0ea5d0e876 feat(buzz): widen group REQ to richer kinds + kind-aware renderers
Read-path interop. Two changes:

1. RelayGroupFilterBuilders: BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS now also
   requests canvas (40100), forum posts/votes/comments (45001-45003), agent
   jobs (43001-43006), and huddle lifecycle (48100-48103) — all h-scoped, so
   the same #h group REQ returns them. Previously only 40002/40003/40008/40099
   were requested, so LocalCache could consume these kinds but they never
   arrived for a group feed.

2. RenderBuzzNotes: kind-aware rows dispatched from ChatMessageCompose —
   RenderBuzzDiff (40008: monospace, horizontally-scrollable diff with a
   repo/commit/file header instead of prose-wrapped gutters), RenderBuzzActivityRow
   (job + huddle lifecycle as centered system narration — huddles MUST be caught
   here since their content is JSON), and RenderBuzzForumVote (45002). Forum
   posts stay as plain chat bubbles (their content is already plain text).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:23:26 +00:00
Claude
5137b92046 feat(buzz): NIP-OA attestation issuance in the agent console
Adds AgentAttestationScreen, reached from an "Attest" FAB on the console.
The owner enters an agent pubkey (npub/hex) and optional AttestationConditions
(kind, created_at before/after), and OwnerAttestation.sign() produces the
signed auth tag, displayed for copy to hand to the agent operator out-of-band.

Entirely offline (nothing is published) and gated on a raw private key: the
NIP-OA signature covers a hashed commitment rather than a Nostr event, so
NIP-46 bunker / NIP-55 external-signer accounts get an explanation instead of
the form. Inputs are validated (kind 0-65535, unix bounds 0-u32, npub/hex key,
no self-attestation) with human-readable errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 00:36:15 +00:00
Claude
2124409025 docs(buzz): document the agent-owner console in the buzz README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:35:42 +00:00
Claude
1b76dd250e feat(buzz): live observer telemetry tab in agent console
Adds a third Observer tab streaming ephemeral agent telemetry frames
(ObserverFrameEvent, kind:24200, p=owner) live from every Buzz-dialect
relay via subscribeAsFlow. Frames are decrypted (frame:telemetry only),
deduped across relays, sorted newest-first, and bounded to a 200-row ring.
The live REQ is opened only while the Observer tab is on screen (started
in a DisposableEffect, cancelled on dispose and in onCleared) because
observer frames are never persisted by relays or LocalCache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:34:29 +00:00
Claude
201aafb5bf 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
2026-07-21 23:26:58 +00:00
Claude
81fa81612b feat(commons): agent fleet cost aggregator for the owner console
Pure aggregation of decrypted NIP-AM turn metrics (kind:44200) into per-agent
and fleet token/cost totals — the data core of the agent-owner console.

Correctness that makes the numbers trustworthy: turns group by (agent,
sessionId); within a session `cumulative` is the monotonic running total, so the
max cumulative per field is the authoritative session total (robust to dropped
turns), falling back to summing per-turn deltas only where no cumulative exists —
per field, so a cumulative that omits costUsd still gets cost from deltas.
Delta-sums that include a deltaReliable=false turn flag the fleet estimate.

10 tests cover cumulative-not-summed, missing-turn recovery, delta fallback,
per-field mixing, multi-session/multi-agent rollup, sessionless singletons, and
the unreliable-estimate flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:14:09 +00:00
40 changed files with 3506 additions and 14 deletions

View File

@@ -153,6 +153,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
@@ -2903,6 +2904,17 @@ class Account(
follow(channel)
}
/**
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
*/
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
if (!isWriteable()) return
val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
client.publish(signed, setOf(channel.groupId.relayUrl))
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)

View File

@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
@@ -4666,7 +4667,13 @@ object LocalCache : ILocalCache, ICacheProvider {
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
// pollute the note store, and do NOT mark the dialect from them (they are
// never stored, so the verified-mark gate has nothing to check).
is TypingIndicatorEvent -> false
is TypingIndicatorEvent -> {
// Live "who is typing" side-effect only — record the heartbeat into the
// process-wide typing state (the channel view collects it) and return
// false so it never becomes a feed row. Own-typing is filtered in the UI.
event.channelId()?.let { BuzzTypingState.record(it, event.pubKey, event.createdAt, TimeUtils.now()) }
false
}
is ObserverFrameEvent -> false
is HuddleReactionEvent -> false
// Pairing (24134) is deliberately dialect-neutral: it flows during device

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
@@ -135,7 +137,7 @@ class AuthCoordinator(
// Remember why we granted this relay so the settings screen can explain it.
account.relayAuthLedger.recordGrant(context)
try {
signed.add(account.signer.sign(authTemplate))
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
}
@@ -169,6 +171,27 @@ class AuthCoordinator(
}
}
/**
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
* appended — so the relay grants virtual membership to an un-enrolled agent key while
* its owner stays a member. Otherwise returns [template] unchanged.
*
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
* add an `auth` tag where one isn't wanted.
*/
private fun buzzAugmented(
template: EventTemplate<RelayAuthEvent>,
accountPubKey: HexKey,
relayUrl: NormalizedRelayUrl,
): EventTemplate<RelayAuthEvent> {
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
}
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()

View File

@@ -99,6 +99,11 @@ 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.AgentAttestationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
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
@@ -453,9 +458,13 @@ fun BuildNavigation(
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzWorkspaces> { BuzzWorkspacesScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
composableFromEnd<Route.AgentConsole> { AgentConsoleScreen(accountViewModel, nav) }
composableFromEnd<Route.AgentAttestation> { AgentAttestationScreen(accountViewModel, nav) }
composableFromEndArgs<Route.AgentPersonaEdit> { AgentPersonaEditScreen(it.slug, accountViewModel, nav) }
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
@@ -752,6 +761,7 @@ fun BuildNavigation(
nav = nav,
)
}
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(

View File

@@ -69,6 +69,7 @@ enum class NavBarItem {
PODCASTS,
PUBLIC_CHATS,
RELAY_GROUPS,
BUZZ,
CONCORD,
GEOHASH_CHATS,
FOLLOW_PACKS,
@@ -344,6 +345,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Forum,
resolveRoute = { Route.RelayGroups },
),
NavBarItem.BUZZ to
NavBarItemDef(
id = NavBarItem.BUZZ,
labelRes = R.string.buzz_workspaces_title,
icon = MaterialSymbols.AutoAwesome,
resolveRoute = { Route.BuzzWorkspaces },
),
NavBarItem.CONCORD to
NavBarItemDef(
id = NavBarItem.CONCORD,
@@ -496,6 +504,7 @@ val BottomBarCategories: List<NavBarCategory> =
listOf(
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.BUZZ,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
),

View File

@@ -469,6 +469,14 @@ sealed class Route {
@Serializable object EditNestsServers : Route()
@Serializable object AgentConsole : Route()
@Serializable object AgentAttestation : Route()
@Serializable data class AgentPersonaEdit(
val slug: String? = null,
) : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@Serializable object EditPaymentTargets : Route()
@@ -691,6 +699,10 @@ sealed class Route {
val relayUrl: String,
) : Route()
@Serializable data class BuzzCanvas(
val channelId: String,
) : Route()
@Serializable data class RelayGroupCreate(
val relayUrl: String,
) : Route()
@@ -702,6 +714,8 @@ sealed class Route {
@Serializable object RelayGroups : Route()
@Serializable object BuzzWorkspaces : Route()
@Serializable object RelayGroupBrowse : Route()
// Concord Channels (encrypted communities). Addressed by community id + channel id

View File

@@ -685,6 +685,11 @@ class AccountViewModel(
account.sendConcordTyping(communityId, channelIdHex)
}
fun sendBuzzTyping(channel: RelayGroupChannel) =
viewModelScope.launch(Dispatchers.IO) {
account.sendBuzzTyping(channel)
}
@Immutable
data class NoteComposeReportState(
val isPostHidden: Boolean = false,

View File

@@ -137,6 +137,10 @@ private fun PreloadFor(
// this tab needs no extra preload.
NavBarItem.RELAY_GROUPS -> Unit
// Buzz workspaces are relay groups on Buzz-dialect relays; they ride the same
// always-on relay-group state subscription, so there's nothing extra to preload.
NavBarItem.BUZZ -> Unit
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)

View File

@@ -0,0 +1,469 @@
/*
* 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.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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
/**
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
* ([OwnerAttestation]) authorizing an agent pubkey to publish under optional
* [AttestationConditions]; the agent then attaches the resulting `auth` tag to its
* events and the relay grants it virtual membership while the owner stays a member.
*
* This is entirely offline: the signature covers a hashed commitment string, not a
* Nostr event, so it needs the owner's **raw private key** — a NIP-46 bunker or NIP-55
* external signer cannot produce it. Read-only accounts see an explanation instead of
* the form. Nothing is published; the signed tag is a credential the owner hands to the
* agent operator out-of-band.
*/
@Composable
fun AgentAttestationScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val keyPair = accountViewModel.account.settings.keyPair
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
Scaffold(
topBar = { TopBarWithBackButton("Attestations", nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Agent side: hold an attestation an owner gave you, so this account
// authenticates to the owner's Buzz relays as a virtual member. Available to
// any signer — holding a credential doesn't require the raw key.
HoldAttestationSection(myPubkey = myPubkey)
// Owner side: issue an attestation for an agent key. Needs the raw private key.
val privKey = keyPair.privKey
if (privKey == null) {
ReadOnlyKeyNotice()
} else {
AttestationForm(ownerKey = keyPair)
}
}
}
}
/**
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
* coordinator attaches it when this account AUTHs to a Buzz relay. In-memory only for
* now — re-paste after an app restart.
*/
@Composable
private fun HoldAttestationSection(myPubkey: String) {
val held by BuzzHeldAttestations.flow.collectAsState()
val mine = held[myPubkey]
var input by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Hold an attestation",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (mine != null) {
Text(
text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
Text("Remove")
}
} else {
Text(
text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = input,
onValueChange = {
input = it
error = null
},
label = { Text("auth tag JSON") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
error?.let {
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
}
Button(
onClick = {
when (val outcome = parseHeldAttestation(input, myPubkey)) {
is HoldOutcome.Failure -> error = outcome.message
is HoldOutcome.Success -> {
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
input = ""
error = null
}
}
},
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Hold attestation")
}
}
}
}
}
private sealed interface HoldOutcome {
data class Success(
val attestation: OwnerAttestation,
) : HoldOutcome
data class Failure(
val message: String,
) : HoldOutcome
}
/**
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
* non-`auth` tag, or a signature that doesn't verify for this key.
*/
private fun parseHeldAttestation(
input: String,
myPubkey: String,
): HoldOutcome {
val tag =
try {
Json
.parseToJsonElement(input.trim())
.jsonArray
.map { it.jsonPrimitive.content }
.toTypedArray()
} catch (e: Exception) {
return HoldOutcome.Failure("Not a valid JSON tag array. Paste the [\"auth\", …] tag you were given.")
}
val attestation =
OwnerAttestation.parse(tag)
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
if (!attestation.verify(myPubkey)) {
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
}
return HoldOutcome.Success(attestation)
}
@Composable
private fun ReadOnlyKeyNotice() {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Local key required",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text =
"A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, " +
"so it can only be produced by a signer that holds your raw private key. This " +
"account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot " +
"sign an attestation.",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun AttestationForm(ownerKey: KeyPair) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var agentInput by remember { mutableStateOf("") }
var kindInput by remember { mutableStateOf("") }
var afterInput by remember { mutableStateOf("") }
var beforeInput by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var result by remember { mutableStateOf<OwnerAttestation?>(null) }
Text(
text =
"Authorize an agent pubkey to publish in your workspace without enrolling its key. " +
"The agent attaches the signed tag below to its events.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = agentInput,
onValueChange = {
agentInput = it
error = null
result = null
},
label = { Text("Agent public key (npub or hex)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = "Conditions (optional) — leave blank for an unrestricted attestation.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = kindInput,
onValueChange = {
kindInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Restrict to kind (065535)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = afterInput,
onValueChange = {
afterInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Only events after (unix seconds)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = beforeInput,
onValueChange = {
beforeInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Only events before (unix seconds)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = {
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
is AttestationOutcome.Failure -> {
error = outcome.message
result = null
}
is AttestationOutcome.Success -> {
error = null
result = outcome.attestation
}
}
},
enabled = agentInput.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Generate attestation")
}
result?.let { attestation ->
AttestationResultCard(
attestation = attestation,
onCopy = { scope.launch { clipboard.setText(attestation.toTagJson()) } },
)
}
}
@Composable
private fun AttestationResultCard(
attestation: OwnerAttestation,
onCopy: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Signed attestation",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text =
"Grants: " +
(attestation.conditions.ifEmpty { "any kind, any time (unrestricted)" }),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = attestation.toTagJson(),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(onClick = onCopy) {
Text("Copy tag")
}
}
Text(
text =
"⚠ Hand this to the agent operator only. While it is valid and you remain a " +
"workspace member, the relay lets this agent post as a member under the " +
"conditions above.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
private sealed interface AttestationOutcome {
data class Success(
val attestation: OwnerAttestation,
) : AttestationOutcome
data class Failure(
val message: String,
) : AttestationOutcome
}
/**
* Validates the form inputs and signs an [OwnerAttestation], or returns a
* human-readable failure. Kept out of composition so the signing (raw-key) path is a
* plain function.
*/
private fun buildAttestation(
agentInput: String,
kindInput: String,
afterInput: String,
beforeInput: String,
ownerKey: KeyPair,
): AttestationOutcome {
val agentHex =
decodePublicKeyAsHexOrNull(agentInput.trim())
?: return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
if (!agentHex.isValid()) {
return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
}
val kind =
if (kindInput.isBlank()) {
null
} else {
val parsed = kindInput.toIntOrNull()
if (parsed == null || parsed !in 0..65535) {
return AttestationOutcome.Failure("Kind must be between 0 and 65535.")
}
parsed
}
val after = parseOptionalUnix(afterInput) ?: return AttestationOutcome.Failure("\"After\" must be a unix time in 04294967295.")
val before = parseOptionalUnix(beforeInput) ?: return AttestationOutcome.Failure("\"Before\" must be a unix time in 04294967295.")
val conditions = AttestationConditions(kind = kind, createdAtBefore = before.value, createdAtAfter = after.value)
return try {
AttestationOutcome.Success(OwnerAttestation.sign(agentHex, conditions, ownerKey))
} catch (e: IllegalArgumentException) {
AttestationOutcome.Failure(e.message ?: "Could not sign the attestation.")
}
}
/** Wraps a nullable parse so "absent" and "invalid" are distinguishable from the caller. */
private class OptionalUnix(
val value: Long?,
)
private fun parseOptionalUnix(input: String): OptionalUnix? {
if (input.isBlank()) return OptionalUnix(null)
val parsed = input.toLongOrNull() ?: return null
if (parsed !in 0..4294967295L) return null
return OptionalUnix(parsed)
}
/** Serializes the `auth` tag to a JSON array string (values are hex / canonical ASCII). */
private fun OwnerAttestation.toTagJson(): String = toTag().joinToString(prefix = "[", postfix = "]") { "\"$it\"" }

View File

@@ -0,0 +1,404 @@
/*
* 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.clickable
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.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.DisposableEffect
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.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
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.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* The workspace owner's agent-owner console — a dashboard over the fleet of AI agents
* that publish to them. Three 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`).
* - **Observer** — a live stream of ephemeral NIP-AO telemetry frames (`kind:24200`).
*
* A "Attest" FAB opens [AgentAttestationScreen] to issue a NIP-OA authorization for an
* agent key. Data comes from [AgentConsoleViewModel], keyed by the owner pubkey so the
* fetch/decrypt work survives navigation. The tabs are read-only; persona editing is a
* follow-up.
*/
@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 observerFrames by viewModel.observerFrames.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
val tabs = remember { listOf("Costs", "Personas", "Observer") }
// The observer stream is a live REQ; only keep it open while its tab is on screen.
if (selectedTab == 2) {
DisposableEffect(Unit) {
viewModel.startObserving()
onDispose { viewModel.stopObserving() }
}
}
Scaffold(
topBar = { TopBarWithBackButton("Agent Console", nav) },
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text("Attest") },
icon = { Icon(symbol = MaterialSymbols.Key, contentDescription = null) },
onClick = { nav.nav(Route.AgentAttestation) },
)
},
) { 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)
1 -> PersonasTab(personas, nav)
else -> ObserverTab(observerFrames)
}
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>,
nav: INav,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
OutlinedButton(
onClick = { nav.nav(Route.AgentPersonaEdit()) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AddCircle, contentDescription = null)
Text(" New persona")
}
}
if (personas.isEmpty()) {
item {
Text(
text = "No personas published yet. Create one to define an agent (kind:30175).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
items(personas) { persona ->
PersonaCardView(persona) { nav.nav(Route.AgentPersonaEdit(persona.slug)) }
}
}
}
@Composable
private fun PersonaCardView(
persona: AgentConsoleViewModel.PersonaCard,
onClick: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
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 ObserverTab(frames: List<AgentConsoleViewModel.ObserverRow>) {
if (frames.isEmpty()) {
EmptyState("Listening for live agent telemetry (kind:24200). Frames appear here while your agents are running.")
return
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(frames) { frame ->
ObserverFrameRow(frame)
HorizontalDivider()
}
}
}
@Composable
private fun ObserverFrameRow(frame: AgentConsoleViewModel.ObserverRow) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = frame.kind,
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
Text(
text = frame.timestamp,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val context =
buildString {
append(shortKey(frame.agentPubKey))
frame.sessionId?.let { append(" · session ${it.take(8)}") }
frame.turnId?.let { append(" · turn ${it.take(8)}") }
append(" · #${frame.seq}")
}
Text(
text = context,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@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)

View File

@@ -0,0 +1,266 @@
/*
* 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.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
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.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.Collections
/**
* 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()
private val _observerFrames = MutableStateFlow<List<ObserverRow>>(emptyList())
val observerFrames: StateFlow<List<ObserverRow>> = _observerFrames.asStateFlow()
private var observerJob: Job? = null
/** Dedups ephemeral frames across relays and re-emissions (accessed off multiple readers). */
private val observerSeen = Collections.synchronizedSet(HashSet<HexKey>())
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)
}
/**
* Opens a live subscription to the owner's ephemeral observer telemetry frames
* ([ObserverFrameEvent], `kind:24200`, `p` = owner) across every Buzz-dialect relay,
* decrypts the `frame:telemetry` bodies, and pushes them newest-first into
* [observerFrames] (bounded to [MAX_OBSERVER_ROWS]). Idempotent; call [stopObserving]
* to tear the subscription down. Observer frames are never stored by relays or
* [LocalCache], so this live REQ is the only way to see them.
*/
fun startObserving() {
val account = account ?: return
if (observerJob != null) return
val relays = BuzzRelayDialect.flow.value
if (relays.isEmpty()) return
val signer = account.signer
val myPubkey = account.userProfile().pubkeyHex
val filter = Filter(kinds = listOf(ObserverFrameEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
observerJob =
viewModelScope.launch(Dispatchers.IO) {
relays.forEach { relay ->
launch {
account.client.subscribeAsFlow(relay, filter).collect { events ->
val fresh =
events
.filterIsInstance<ObserverFrameEvent>()
.filter { observerSeen.add(it.id) }
if (fresh.isEmpty()) return@collect
val rows =
fresh.mapNotNull { frame ->
if (frame.frame() != FrameTag.TELEMETRY) return@mapNotNull null
val payload = frame.decryptTelemetryOrNull(signer) ?: return@mapNotNull null
ObserverRow(
seq = payload.seq,
timestamp = payload.timestamp,
kind = payload.kind,
agentPubKey = frame.agentPubKey() ?: frame.pubKey,
sessionId = payload.sessionId,
turnId = payload.turnId,
)
}
if (rows.isNotEmpty()) {
_observerFrames.update { existing ->
(rows + existing)
.sortedByDescending { it.timestamp }
.take(MAX_OBSERVER_ROWS)
}
}
}
}
}
}
}
fun stopObserving() {
observerJob?.cancel()
observerJob = null
}
override fun onCleared() {
stopObserving()
super.onCleared()
}
/** One decrypted observer telemetry frame rendered on the Observer tab. */
@Immutable
data class ObserverRow(
val seq: Long,
val timestamp: String,
val kind: String,
val agentPubKey: HexKey,
val sessionId: String?,
val turnId: String?,
)
/** 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?,
)
companion object {
/** Ring size for the ephemeral observer stream — enough to scroll recent activity. */
const val MAX_OBSERVER_ROWS = 200
}
}

View File

@@ -0,0 +1,145 @@
/*
* 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.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Create or edit a Buzz Agent Persona (NIP-AP `kind:30175`). [slug] null → a new persona;
* otherwise edits the existing one (slug locked). Publishes to the workspace's Buzz relays
* on save and pops back. Backed by [AgentPersonaEditViewModel], keyed by owner+slug so an
* in-progress edit survives recomposition.
*/
@Composable
fun AgentPersonaEditScreen(
slug: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: AgentPersonaEditViewModel = viewModel(key = "PersonaEdit-$pubkey-${slug ?: "new"}")
viewModel.bind(accountViewModel.account, slug)
val state by viewModel.state.collectAsStateWithLifecycle()
val title = if (slug == null) "New persona" else "Edit persona"
Scaffold(
topBar = { TopBarWithBackButton(title, nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = state.slug,
onValueChange = viewModel::onSlugChange,
label = { Text("Slug (persona id)") },
singleLine = true,
enabled = !state.slugLocked,
supportingText = { Text("a-z, 0-9, '-' or '_'. Cannot change after creation.") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.displayName,
onValueChange = viewModel::onDisplayNameChange,
label = { Text("Display name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.systemPrompt,
onValueChange = viewModel::onSystemPromptChange,
label = { Text("System prompt") },
minLines = 3,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.model,
onValueChange = viewModel::onModelChange,
label = { Text("Model (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.provider,
onValueChange = viewModel::onProviderChange,
label = { Text("Provider (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.runtime,
onValueChange = viewModel::onRuntimeChange,
label = { Text("Runtime (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.avatarUrl,
onValueChange = viewModel::onAvatarUrlChange,
label = { Text("Avatar URL (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
state.error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = { viewModel.save(onDone = nav::popBack) },
enabled = state.canSave,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.isSaving) "Publishing…" else "Publish persona")
}
}
}
}

View File

@@ -0,0 +1,174 @@
/*
* 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.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaContent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Backing ViewModel for [AgentPersonaEditScreen] — creates or edits a Buzz Agent Persona
* (NIP-AP `kind:30175`), a world-readable addressable event authored by the workspace
* owner and addressed by `(owner, 30175, slug)`.
*
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
* not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather
* than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a
* different slug is a different persona.
*
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
* none are known), so the workspace and other members see it.
*/
class AgentPersonaEditViewModel : ViewModel() {
@Volatile private var account: Account? = null
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
private var existing: PersonaContent? = null
private var editingSlug: String? = null
private val _state = MutableStateFlow(FormState())
val state: StateFlow<FormState> = _state.asStateFlow()
/** Binds the account and, when [slug] names an existing persona, seeds the form from it. */
fun bind(
account: Account,
slug: String?,
) {
if (this.account != null) return
this.account = account
if (slug.isNullOrBlank()) return
val note = LocalCache.getAddressableNoteIfExists(Address(PersonaEvent.KIND, account.userProfile().pubkeyHex, slug))
val event = note?.event as? PersonaEvent ?: return
val content = event.personaOrNull() ?: return
existing = content
editingSlug = slug
_state.value =
FormState(
slug = slug,
slugLocked = true,
displayName = content.displayName,
systemPrompt = content.systemPrompt.orEmpty(),
model = content.model.orEmpty(),
runtime = content.runtime.orEmpty(),
provider = content.provider.orEmpty(),
avatarUrl = content.avatarUrl.orEmpty(),
)
}
fun onSlugChange(v: String) = _state.update { it.copy(slug = v.trim(), error = null) }
fun onDisplayNameChange(v: String) = _state.update { it.copy(displayName = v, error = null) }
fun onSystemPromptChange(v: String) = _state.update { it.copy(systemPrompt = v, error = null) }
fun onModelChange(v: String) = _state.update { it.copy(model = v.trim(), error = null) }
fun onRuntimeChange(v: String) = _state.update { it.copy(runtime = v.trim(), error = null) }
fun onProviderChange(v: String) = _state.update { it.copy(provider = v.trim(), error = null) }
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
/**
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
*/
fun save(onDone: () -> Unit) {
val account = account ?: return
val current = _state.value
val slug = current.slug.trim()
if (!isValidSlug(slug)) {
_state.update { it.copy(error = "Slug must match a-z, 0-9, '-' or '_' (max 64 chars).") }
return
}
if (current.displayName.isBlank()) {
_state.update { it.copy(error = "Display name is required.") }
return
}
_state.update { it.copy(isSaving = true, error = null) }
viewModelScope.launch(Dispatchers.IO) {
try {
val base = existing ?: PersonaContent(displayName = current.displayName)
val content =
base.copy(
displayName = current.displayName.trim(),
systemPrompt = current.systemPrompt.blankToNull(),
model = current.model.blankToNull(),
runtime = current.runtime.blankToNull(),
provider = current.provider.blankToNull(),
avatarUrl = current.avatarUrl.blankToNull(),
)
val template = PersonaEvent.build(content, slug)
account.signAndSendPrivatelyOrBroadcast(template) {
BuzzRelayDialect.flow.value
.toList()
.ifEmpty { null }
}
viewModelScope.launch(Dispatchers.Main) { onDone() }
} catch (e: Exception) {
if (e is CancellationException) throw e
_state.update { it.copy(isSaving = false, error = "Failed to publish: ${e.message ?: e::class.simpleName}") }
}
}
}
private fun String.blankToNull(): String? = trim().takeIf { it.isNotBlank() }
data class FormState(
val slug: String = "",
/** True when editing an existing persona — the slug (address `d` tag) can't change. */
val slugLocked: Boolean = false,
val displayName: String = "",
val systemPrompt: String = "",
val model: String = "",
val runtime: String = "",
val provider: String = "",
val avatarUrl: String = "",
val isSaving: Boolean = false,
val error: String? = null,
) {
val canSave: Boolean get() = slug.isNotBlank() && displayName.isNotBlank() && !isSaving
}
companion object {
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]{0,63}$")
/** The persona slug grammar enforced by the relay (`validate_persona_envelope`). */
fun isValidSlug(slug: String): Boolean = SLUG_REGEX.matches(slug)
}
}

View File

@@ -0,0 +1,111 @@
/*
* 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.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Renders a Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown
* document for a channel. The canvas is overlay state held in [BuzzWorkspaceStates]
* (never a timeline row — a workspace has one live canvas, last-write-wins), so this
* reads the registry by the channel's `h` id and re-composes off `canvasUpdates` when a
* newer revision lands.
*/
@Composable
fun BuzzCanvasScreen(
channelId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Re-read canvasNote whenever a newer canvas is consumed.
val version by state.canvasUpdates.collectAsStateWithLifecycle()
val canvas = remember(version) { state.canvasNote }
val content = canvas?.event?.content
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) },
) { padding ->
if (content.isNullOrBlank() || canvas == null) {
Box(
modifier = Modifier.padding(padding).fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.buzz_canvas_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
return@Scaffold
}
val bgColor = remember { mutableStateOf(Color.Transparent) }
Box(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
TranslatableRichTextViewer(
content = content,
canPreview = true,
quotesLeft = 1,
modifier = Modifier,
tags = EmptyTagList,
backgroundColor = bgColor,
id = canvas.idHex,
callbackUri = canvas.toNostrUri(),
authorPubKey = canvas.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}

View File

@@ -0,0 +1,341 @@
/*
* 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.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* The **Workspaces** tab — a first-class navigation home for `block/buzz` workspaces,
* giving Buzz a distinct identity instead of hiding inside the generic relay-group list.
* A Buzz workspace is a NIP-29 group on a Buzz-dialect relay, so this filters the user's
* joined groups to those relays ([BuzzRelayDialect]) and adds the Buzz-only surfaces (the
* Agent Console) up top.
*
* Read-only aggregation: it reuses the always-on relay-group state subscription (mounted
* at login), so no per-screen fetch is needed — it just projects the joined set.
*/
@Composable
fun BuzzWorkspacesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
val workspaces =
remember(joined, buzzRelays) {
joined
.mapNotNull { tag ->
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
if (relay !in buzzRelays) return@mapNotNull null
GroupId(tag.groupId, relay)
}.sortedBy { it.id }
}
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
UserDrawerSearchTopBar(accountViewModel, nav) {
Text(
text = stringRes(R.string.buzz_workspaces_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
}
},
bottomBar = {
AppBottomBar(Route.BuzzWorkspaces, nav, accountViewModel) { route ->
nav.navBottomBar(route)
}
},
accountViewModel = accountViewModel,
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { AgentConsoleHeroCard(onClick = { nav.nav(Route.AgentConsole) }) }
if (workspaces.isEmpty()) {
item { EmptyWorkspaces(onBrowse = { nav.nav(Route.RelayGroups) }) }
} else {
item {
Text(
text = stringRes(R.string.buzz_workspaces_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
)
}
items(workspaces, key = { it.id + it.relayUrl.url }) { groupId ->
WorkspaceRow(groupId, accountViewModel, nav)
}
}
}
}
}
/** A bold accent card leading to the Buzz-only Agent Console — the tab's signature surface. */
@Composable
private fun AgentConsoleHeroCard(onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(24.dp),
)
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.buzz_console_card_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
text = stringRes(R.string.buzz_console_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(22.dp),
)
}
}
}
/** One workspace: a colored monogram avatar, live name + host, member count, unread dot. */
@Composable
private fun WorkspaceRow(
groupId: GroupId,
accountViewModel: AccountViewModel,
nav: INav,
) {
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val hasUnread by remember(groupId) {
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
}.collectAsStateWithLifecycle(initialValue = false)
val name = channel.toBestDisplayName()
val memberCount = channel.memberCount()
Card(
onClick = { nav.nav(routeFor(channel)) },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
WorkspaceAvatar(name = name, seed = groupId.id)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = groupId.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (memberCount > 0) {
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(3.dp))
Text(
text = "$memberCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (hasUnread) {
Spacer(Modifier.width(10.dp))
Box(
modifier =
Modifier
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
}
/** A round monogram whose color is derived deterministically from the workspace id. */
@Composable
private fun WorkspaceAvatar(
name: String,
seed: String,
) {
val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f }
val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) }
val initial =
remember(name) {
name
.trim()
.firstOrNull()
?.uppercaseChar()
?.toString() ?: "#"
}
Box(
modifier = Modifier.size(42.dp).clip(CircleShape).background(color),
contentAlignment = Alignment.Center,
) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
}
/** Inviting empty state: what a Buzz workspace is + a way to go find one. */
@Composable
private fun EmptyWorkspaces(onBrowse: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp),
)
}
Text(
text = stringRes(R.string.buzz_workspaces_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringRes(R.string.buzz_workspaces_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp),
)
Spacer(Modifier.size(2.dp))
FilledTonalButton(onClick = onBrowse) {
Text(stringRes(R.string.buzz_workspaces_browse))
}
}
}
}

View File

@@ -80,6 +80,7 @@ fun RefreshingChatroomFeedView(
// callers with no external jump affordance.
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
listStateObserver(listState)
@@ -97,6 +98,7 @@ fun RefreshingChatroomFeedView(
sentinels,
jumpToNoteId,
onJumpHandled,
onWantsToEditBuzz,
)
}
}
@@ -116,6 +118,7 @@ fun RenderChatFeedView(
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
val feedState by feed.feedContent.collectAsStateWithLifecycle()
@@ -148,6 +151,7 @@ fun RenderChatFeedView(
sentinels,
jumpToNoteId,
onJumpHandled,
onWantsToEditBuzz,
)
}
}
@@ -169,6 +173,7 @@ fun ChatFeedLoaded(
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -263,6 +268,7 @@ fun ChatFeedLoaded(
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
onWantsToEditBuzz = onWantsToEditBuzz,
)
}
}

View File

@@ -92,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -119,6 +120,7 @@ fun ChatMessageActionSheet(
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
var showShareSheet by remember { mutableStateOf(false) }
var wantsToEditPost by remember { mutableStateOf(false) }
@@ -265,6 +267,23 @@ fun ChatMessageActionSheet(
// Stage one: the primary chat action (reply / edit draft) is always shown.
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
// Buzz: edit my own kind-40002 stream message (publishes a kind-40003 edit).
// A 40002 event is inherently a Buzz message, so the type alone is the gate;
// authorship restricts it to my own messages.
val canEditBuzz =
onWantsToEditBuzz != null &&
note.event is StreamMessageV2Event &&
note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
if (canEditBuzz) {
SectionDivider()
TileRow {
ActionTile(MaterialSymbols.Edit, stringRes(R.string.buzz_edit_message)) {
onWantsToEditBuzz!!(note)
onDismiss()
}
}
}
val handlers =
NoteActionHandlers(
onShare = { showShareSheet = true },

View File

@@ -56,7 +56,10 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
@@ -67,9 +70,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
@@ -108,6 +114,9 @@ fun ChatroomMessageCompose(
// reply quotes inside a DM, where the target is simply older than the loaded window (see
// LoadingReplyNote). Null keeps the default blank for every other caller.
onBlank: (@Composable () -> Unit)? = null,
// Buzz-only: edit my own kind-40002 stream message (publishes a 40003 edit). Null for
// every non-Buzz chat surface, which hides the action.
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
// Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted
// chat message renders with the chat reply design instead of the quoted-note card.
@@ -136,6 +145,16 @@ fun ChatroomMessageCompose(
} else if (event is SystemMessageEvent) {
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
RenderBuzzSystemMessage(baseNote)
} else if (event is StreamMessageDiffEvent) {
// Buzz kind-40008: a code/text diff pushed into the channel.
RenderBuzzDiff(baseNote)
} else if (event is ForumVoteEvent) {
// Buzz kind-45002: a forum up/down vote.
RenderBuzzForumVote(baseNote)
} else if (isBuzzActivityRow(event)) {
// Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles
// especially must be caught here — their content is JSON, not chat text.
RenderBuzzActivityRow(baseNote)
} else {
NormalChatNote(
baseNote,
@@ -152,6 +171,7 @@ fun ChatroomMessageCompose(
onHighlightFinished,
groupPosition,
previousNoteId,
onWantsToEditBuzz,
)
}
}
@@ -182,6 +202,7 @@ fun NormalChatNote(
onHighlightFinished: (() -> Unit)? = null,
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
previousNoteId: HexKey? = null,
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
// A geohash chat renders "as" its anonymous per-cell identity (and the account, when posting as
// self); LocalChatActingIdentities lets the renderer treat those pubkeys as "me" (alignment,
@@ -312,6 +333,7 @@ fun NormalChatNote(
onDismiss = onDismiss,
accountViewModel = accountViewModel,
nav = nav,
onWantsToEditBuzz = onWantsToEditBuzz,
)
},
reactionsRow =

View File

@@ -20,7 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -30,6 +35,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
@@ -41,7 +49,20 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatSystemMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
/**
@@ -131,3 +152,117 @@ fun RenderBuzzSystemMessage(note: Note) {
ChatSystemMessage(text = text)
}
/**
* True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow]
* narrates as a centered system line rather than a chat bubble. Huddle events in
* particular MUST be caught here — their `content` is JSON, so rendering them as a plain
* chat message would show raw `{"ephemeral_channel_id":…}`.
*/
fun isBuzzActivityRow(event: Event?): Boolean =
event is JobRequestEvent ||
event is JobAcceptedEvent ||
event is JobProgressEvent ||
event is JobResultEvent ||
event is JobCancelEvent ||
event is JobErrorEvent ||
event is HuddleStartedEvent ||
event is HuddleParticipantJoinedEvent ||
event is HuddleParticipantLeftEvent ||
event is HuddleEndedEvent
/**
* Narrates a Buzz agent-job or huddle lifecycle event as a centered system line. The
* label is derived from the kind; job progress/result/error also append a short content
* snippet (the human-readable status/result/error the agent wrote).
*/
@Composable
fun RenderBuzzActivityRow(note: Note) {
val event = note.event ?: return
val text =
remember(event) {
when (event) {
is JobRequestEvent -> "⚙ job requested" + event.request().snippet()
is JobAcceptedEvent -> "⚙ job accepted"
is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet()
is JobResultEvent -> "⚙ job result" + event.result().snippet()
is JobCancelEvent -> "⚙ job cancelled"
is JobErrorEvent -> "⚠ job error" + event.error().snippet()
is HuddleStartedEvent -> "🔊 huddle started"
is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle"
is HuddleParticipantLeftEvent -> "🔊 someone left the huddle"
is HuddleEndedEvent -> "🔊 huddle ended"
else -> event.content.take(120)
}
}
ChatSystemMessage(text = text)
}
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
private fun String.snippet(): String {
val oneLine = trim().replace('\n', ' ')
if (oneLine.isEmpty()) return ""
return ": " + if (oneLine.length > 80) oneLine.take(80) + "" else oneLine
}
/**
* A Buzz kind-40008 stream diff: a code/text diff pushed into the channel. Renders the
* repo/commit/file header from the `DiffMeta` tags plus the diff body in a monospace,
* horizontally-scrollable block, rather than the plain-text fallback (which mangles the
* `+`/`-` gutters into wrapped prose).
*/
@Composable
fun RenderBuzzDiff(note: Note) {
val event = note.event as? StreamMessageDiffEvent ?: return
val meta = remember(event) { event.diffMeta() }
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
val header =
remember(meta) {
meta?.let {
buildString {
it.filePath?.let(::append)
it.language?.let { lang -> append(if (isEmpty()) lang else " · $lang") }
it.prNumber?.let { pr -> append(" · PR #$pr") }
it.commitSha
?.takeIf { sha -> sha.isNotBlank() }
?.let { sha -> append(" · ${sha.take(8)}") }
}.ifBlank { null }
}
}
if (header != null) {
Text(
text = header,
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
if (meta?.truncated == true) {
Text(
text = stringRes(R.string.buzz_diff_truncated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
}
}
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
@Composable
fun RenderBuzzForumVote(note: Note) {
val event = note.event as? ForumVoteEvent ?: return
// Content is the vote token ("+"/"-" or similar); show a compact glyph line.
val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" }
ChatSystemMessage(text = text)
}

View File

@@ -0,0 +1,187 @@
/*
* 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.chats.publicChannels.relayGroup
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
/**
* A live "… is typing" row for a Buzz workspace channel, driven by the ephemeral
* kind-20002 heartbeats collected into [BuzzTypingState]. Placed just above the composer,
* it slides in when someone starts typing and out when they stop (heartbeats age out of
* the freshness window). Own typing is filtered by [myPubkey].
*
* The three animated dots are a small modern flourish; the wording collapses to a count
* once more than two people type at once so the row never overflows.
*/
@Composable
fun BuzzTypingIndicator(
channelId: String,
myPubkey: HexKey,
accountViewModel: AccountViewModel,
) {
val typingByChannel by BuzzTypingState.flow.collectAsStateWithLifecycle()
// Re-evaluate on a light timer so typists fade out when their heartbeats go stale,
// but only while this channel actually has any — an idle channel never wakes the loop.
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
val raw = typingByChannel[channelId]
LaunchedEffect(channelId, raw) {
if (raw.isNullOrEmpty()) return@LaunchedEffect
while (true) {
nowSecs = TimeUtils.now()
if (raw.values.none { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }) break
delay(2000L)
}
}
val typers =
remember(raw, myPubkey, nowSecs) {
(raw ?: emptyMap())
.filterKeys { it != myPubkey }
.filterValues { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }
.keys
.sorted()
}
AnimatedVisibility(
visible = typers.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TypingDots()
Spacer(Modifier.width(8.dp))
Text(
text = typingLabel(typers, accountViewModel),
style = MaterialTheme.typography.labelSmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
}
}
}
@Composable
private fun typingLabel(
typers: List<HexKey>,
accountViewModel: AccountViewModel,
): String =
when (typers.size) {
1 -> stringRes(R.string.buzz_typing_one, rememberTypistName(typers[0], accountViewModel))
2 ->
stringRes(
R.string.buzz_typing_two,
rememberTypistName(typers[0], accountViewModel),
rememberTypistName(typers[1], accountViewModel),
)
else -> stringRes(R.string.buzz_typing_many)
}
/** Three dots that bounce in a staggered wave — a lightweight, always-on micro-animation. */
@Composable
private fun TypingDots() {
val transition = rememberInfiniteTransition(label = "buzz-typing")
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
repeat(3) { index ->
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 900, delayMillis = index * 150),
repeatMode = RepeatMode.Reverse,
),
label = "dot-$index",
)
Spacer(
modifier =
Modifier
.size(5.dp)
.graphicsLayer {
translationY = -3f * phase
val s = 0.7f + 0.3f * phase
scaleX = s
scaleY = s
}.alpha(0.4f + 0.6f * phase)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
@Composable
private fun rememberTypistName(
hex: HexKey,
accountViewModel: AccountViewModel,
): String {
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
val info by observeUserInfo(user, accountViewModel)
return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() }
}

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
@@ -216,6 +217,7 @@ private fun ChannelView(
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
onWantsToEditBuzz = newPostModel::editBuzzMessage,
jumpToNoteId = jumpToNoteId,
onJumpHandled = { jumpToNoteId.value = null },
// A status card at the oldest end: what it's reaching for while paging, "All caught up" when dry.
@@ -251,6 +253,17 @@ private fun ChannelView(
)
}
// Buzz workspaces surface a live "… is typing" row just above the composer, fed by
// ephemeral kind-20002 heartbeats. Only rendered on a Buzz-dialect relay (the kind is
// Buzz-only); a no-op otherwise.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
BuzzTypingIndicator(
channelId = channel.groupId.id,
myPubkey = accountViewModel.userProfile().pubkeyHex,
accountViewModel = accountViewModel,
)
}
Spacer(modifier = DoubleVertSpacer)
// NIP-29 relays reject writes from non-members, so only show the composer when the

View File

@@ -37,6 +37,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -50,6 +51,8 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
@@ -164,6 +167,24 @@ fun RelayGroupTopBar(
)
}
// Buzz workspace canvas (kind 40100): shown only on a Buzz-dialect relay once
// a canvas has arrived for this channel. Observing canvasUpdates flips it on
// the moment the first canvas is consumed, without swapping the channel object.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
val canvasState = remember(channel.groupId.id) { BuzzWorkspaceStates.getOrCreate(channel.groupId.id) }
val canvasVersion by canvasState.canvasUpdates.collectAsState()
val hasCanvas = remember(canvasVersion) { canvasState.canvasNote != null }
if (hasCanvas) {
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id)) }) {
Icon(
symbol = MaterialSymbols.Dashboard,
contentDescription = stringRes(R.string.buzz_canvas_title),
modifier = Modifier.size(20.dp),
)
}
}
}
val naddr = channel.toNAddr()
if (naddr != null) {
val context = LocalContext.current

View File

@@ -20,6 +20,21 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
@@ -91,12 +106,21 @@ val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* Extra timeline kinds a `block/buzz` workspace relay serves in the same `h`-scoped
* channels: stream messages v2 (40002), edits (40003), diffs (40008) and system rows
* (40099). Requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
* channels, requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
* kinds simply match nothing. A dialect-gated version was tried and reverted: gating
* creates a bootstrap hole (nothing asks for a Buzz kind until one is consumed) and a
* worse one — history pages fetched before the mark advance their cursors past ranges
* queried WITHOUT Buzz kinds, permanently skipping older workspace messages.
*
* All are `h`-scoped (`GroupIdTag`), so the same `#h` group REQ returns them:
* - stream messages v2 (40002), edits (40003), diffs (40008), system rows (40099), canvas (40100)
* - forum posts/votes/comments (45001-45003)
* - agent jobs (43001-43006)
* - huddle lifecycle (48100-48103)
*
* Consumption for every one of these already exists in `LocalCache` (see
* `consumeBuzzTimelineEvent`); requesting them here is what lets them actually arrive for
* a group feed instead of only appearing if another subscription happened to fetch them.
*/
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
listOf(
@@ -104,11 +128,34 @@ val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
StreamMessageEditEvent.KIND,
StreamMessageDiffEvent.KIND,
SystemMessageEvent.KIND,
CanvasEvent.KIND,
ForumPostEvent.KIND,
ForumVoteEvent.KIND,
ForumCommentEvent.KIND,
JobRequestEvent.KIND,
JobAcceptedEvent.KIND,
JobProgressEvent.KIND,
JobResultEvent.KIND,
JobCancelEvent.KIND,
JobErrorEvent.KIND,
HuddleStartedEvent.KIND,
HuddleParticipantJoinedEvent.KIND,
HuddleParticipantLeftEvent.KIND,
HuddleEndedEvent.KIND,
)
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
/**
* Kinds requested on the **open channel's live tail only** — the timeline set plus the
* ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen
* (not the whole joined fleet) because it's a live "someone is typing" signal, never
* stored (20000-29999) and never a feed row (`LocalCache` records it into `BuzzTypingState`
* and drops it). It matches nothing on a vanilla relay.
*/
val RELAY_GROUP_OPEN_TAIL_KINDS = RELAY_GROUP_ALL_TIMELINE_KINDS + TypingIndicatorEvent.KIND
/** Forum-thread kinds shown in a group's Threads tab. */
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
@@ -186,7 +233,7 @@ fun buildRelayGroupOpenChatTailFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
kinds = RELAY_GROUP_OPEN_TAIL_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),

View File

@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
@@ -162,6 +163,11 @@ open class ChannelNewMessageViewModel :
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
val replyMode = mutableStateOf(ReplyMode.INLINE)
// When set, the composer is editing an existing Buzz stream message (kind 40002):
// the next send publishes a kind-40003 edit targeting this note instead of a new
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
val editingBuzzMessage = mutableStateOf<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
// Stripping failure dialog
@@ -270,6 +276,26 @@ open class ChannelNewMessageViewModel :
draftTag.newVersion()
}
/**
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks
* the next send as a kind-40003 edit of it. Editing and replying are mutually
* exclusive, so any pending reply is cleared. The caller gates this to the user's own
* kind-40002 messages on a Buzz relay.
*/
fun editBuzzMessage(note: Note) {
replyTo.value = null
replyMode.value = ReplyMode.INLINE
editingBuzzMessage.value = note
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
draftTag.newVersion()
}
fun clearBuzzEdit() {
editingBuzzMessage.value = null
message.setTextAndPlaceCursorAtEnd("")
draftTag.newVersion()
}
open fun editFromDraft(draft: Note) {
val noteEvent = draft.event
val noteAuthor = draft.author
@@ -687,6 +713,19 @@ open class ChannelNewMessageViewModel :
}
}
channel is RelayGroupChannel &&
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
editingBuzzMessage.value != null -> {
// Buzz edit (kind 40003): replaces the text of an existing kind-40002 message.
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
// message; content is the replacement text. Kept minimal to mirror Buzz's own
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
// re-renders with this content.
val target = editingBuzzMessage.value!!
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message)
}
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
// Buzz workspace message: the native kind is 40002 (stream message v2)
// scoped with the group's `h` tag; Buzz threads replies with NIP-10
@@ -777,6 +816,7 @@ open class ChannelNewMessageViewModel :
message.setTextAndPlaceCursorAtEnd("")
replyTo.value = null
editingBuzzMessage.value = null
urlPreview = null

View File

@@ -22,21 +22,31 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
@@ -56,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.FlowPreview
@@ -87,6 +98,35 @@ fun EditFieldRow(
)
}
// Buzz edit mode: a banner reminding the user the next send replaces an existing
// message (a kind-40003 edit), with an X to abandon the edit and clear the field.
channelScreenModel.editingBuzzMessage.value?.let {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.buzz_editing_banner),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
IconButton(onClick = { channelScreenModel.clearBuzzEdit() }) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.cancel),
modifier = Modifier.size(16.dp),
)
}
}
}
channelScreenModel.uploadState?.let { uploading ->
uploading.multiOrchestrator?.let { _ ->
ChannelFileUploadDialog(
@@ -121,9 +161,28 @@ fun EditFieldRow(
)
}
// Client-side throttle for the Buzz kind-20002 typing heartbeat (below).
val lastTypingSecs = remember { longArrayOf(0L) }
ThinPaddingTextField(
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
onTextChanged = {
channelScreenModel.onMessageChanged()
// Buzz typing indicator: fire a throttled heartbeat while composing in a
// Buzz workspace channel, so other members see "… is typing". No-op on any
// other chat (the kind is Buzz-only and the relay would ignore it anyway).
val channel = channelScreenModel.channel
if (channel is RelayGroupChannel &&
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
channelScreenModel.message.text.isNotEmpty()
) {
val now = TimeUtils.now()
if (now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) {
lastTypingSecs[0] = now
accountViewModel.sendBuzzTyping(channel)
}
}
},
onContentReceived = { uri, mimeType ->
channelScreenModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
},

View File

@@ -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),
),
)

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>
@@ -3289,6 +3291,21 @@
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
<string name="buzz_message_edited">(edited)</string>
<string name="buzz_diff_truncated">(diff truncated)</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
<string name="buzz_edit_message">Edit</string>
<string name="buzz_editing_banner">Editing message</string>
<string name="buzz_typing_one">%1$s is typing…</string>
<string name="buzz_typing_two">%1$s and %2$s are typing…</string>
<string name="buzz_typing_many">Several people are typing…</string>
<string name="buzz_workspaces_title">Workspaces</string>
<string name="buzz_workspaces_section">Your workspaces</string>
<string name="buzz_workspaces_empty_title">No workspaces yet</string>
<string name="buzz_workspaces_empty_body">Buzz workspaces are relays where humans and AI agents build together. Join one to see it here.</string>
<string name="buzz_workspaces_browse">Browse groups</string>
<string name="buzz_console_card_title">Agent Console</string>
<string name="buzz_console_card_subtitle">Costs · Personas · Live observer</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>

View File

@@ -128,10 +128,11 @@ class RelayGroupFilterBuildersTest {
fun `open chat tail is a single host h-filter with since only`() {
val f = buildRelayGroupOpenChatTailFilter(g1OnA, 5L)
assertEquals(relayA, f.relay)
// The open-channel tail always carries the Buzz timeline kinds in addition to
// the NIP-29 set: it is the Buzz-dialect detection bootstrap (see
// buildRelayGroupOpenChatTailFilter). Harmless on vanilla relays.
assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, f.filter.kinds)
// The open-channel tail carries the full Buzz timeline set PLUS the ephemeral
// kind-20002 typing indicator (RELAY_GROUP_OPEN_TAIL_KINDS) — typing is scoped to
// the one channel on screen, not the joined fleet. It is also the Buzz-dialect
// detection bootstrap. Harmless on vanilla relays.
assertEquals(RELAY_GROUP_OPEN_TAIL_KINDS, f.filter.kinds)
assertEquals(listOf("g1"), f.filter.tags!!["h"])
assertEquals(5L, f.filter.since)
assertNull(f.filter.until)

View File

@@ -591,6 +591,22 @@ screen speaks.
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
### Buzz workspaces (block/buzz — NIP-29 dialect)
[`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz
relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native
surface: the kind:40002 stream message, the owner-attestation primitive (NIP-OA), and the
agent console (turn-metric aggregation + personas), all driving the same `quartz` models
and `commons` aggregator the app uses.
| Command | What it does |
| --- | --- |
| `amy buzz post RELAY GID <text>` | Post a kind:40002 stream message (Buzz-native) into a workspace. |
| `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). |
| `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. |
| `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
| `amy buzz personas [--relays R,R] [--timeout SECS]` | List my kind:30175 persona definitions (newest per slug). |
### Concord Channels (encrypted communities)
Encrypted, serverless communities (the CORD specs). Community secrets

View File

@@ -59,6 +59,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| NIP-18 reposts / quotes | 🆕 | |
| NIP-25 reactions | ✅ in groups · 🆕 elsewhere | `marmot message react` covers MLS group reactions; outer-event reactions still pending. |
| NIP-29 relay groups (`amy relaygroup`) | ✅ | `RelayGroupCommands` — list/browse/info/create/join/leave/message/edit/invite/put-user/remove-user against a host relay; kind:10009 joined-list kept in sync. |
| Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). |
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
| NIP-57 zaps (send) | ✅ partial | `ZapCommand``zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. |
| NIP-65 outbox model queries | ✅ | `OutboxCommand``amy outbox USER [--refresh]`, cache-first. |

View File

@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.commands.AdminCommand
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
import com.vitorpamplona.amethyst.cli.commands.BlossomCommands
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
import com.vitorpamplona.amethyst.cli.commands.BuzzCommands
import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
import com.vitorpamplona.amethyst.cli.commands.CountCommand
import com.vitorpamplona.amethyst.cli.commands.CreateCommand
@@ -285,6 +286,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"relay" -> RelayCommands.dispatch(dataDir, tail)
"marmot" -> marmotDispatch(dataDir, tail)
"relaygroup" -> RelayGroupCommands.dispatch(dataDir, tail)
"buzz" -> BuzzCommands.dispatch(dataDir, tail)
"dm" -> DmCommands.dispatch(dataDir, tail)
"geochat" -> GeochatCommands.dispatch(dataDir, tail)
"profile" -> ProfileCommands.dispatch(dataDir, tail)
@@ -802,6 +804,13 @@ private fun printUsage() {
| [--role admin|moderator]
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
|Buzz (block/buzz agent workspaces — NIP-29 dialect):
| buzz post RELAY GID <text> post a kind-40002 stream message
| buzz read RELAY GID [--limit N] read recent workspace messages
| buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline)
| buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics
| buzz personas [--relays R,R] list my kind-30175 personas
|
|Marmot (MLS group messaging):
| marmot key-package publish publish a fresh KeyPackage
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays

View File

@@ -0,0 +1,285 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/**
* `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the
* same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29
* groups, so join/leave/create still go through `amy relaygroup`; this verb group covers
* the Buzz-native pieces: stream messages (40002), the owner-attestation primitive (OA),
* and the agent console (turn-metric aggregation + personas).
*/
object BuzzCommands {
private val USAGE =
"""
|amy buzz post RELAY GID <text> post a kind-40002 stream message
|amy buzz read RELAY GID [--limit N] read recent workspace messages (9/40002/40099)
| [--timeout SECS]
|amy buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline; prints the auth tag)
| [--after UNIX] [--before UNIX]
|amy buzz console [--relays R,R] decrypt + aggregate my kind-44200 turn metrics
| [--timeout SECS]
|amy buzz personas [--relays R,R] list my kind-30175 personas
| [--timeout SECS]
""".trimMargin()
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"buzz",
tail,
USAGE,
mapOf(
"post" to { rest -> post(dataDir, rest) },
"read" to { rest -> read(dataDir, rest) },
"attest" to { rest -> attest(dataDir, rest) },
"console" to { rest -> console(dataDir, rest) },
"personas" to { rest -> personas(dataDir, rest) },
),
)
/** `buzz post RELAY GID <text>` → publishes a kind-40002 stream message with an `h` tag. */
private suspend fun post(
dataDir: DataDir,
rest: Array<String>,
): Int {
val usage = "buzz post RELAY GID <text>"
val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
return publishScoped(dataDir, rest, usage) { _, groupId, _ ->
StreamMessageV2Event.build(groupId, text)
}
}
/** `buzz read RELAY GID [--limit N] [--timeout SECS]` → drains the recent human-visible timeline. */
private suspend fun read(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "buzz read RELAY GID [--limit N] [--timeout SECS]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val limit = args.flag("limit")?.toIntOrNull() ?: 50
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
args.rejectUnknown("limit", "timeout")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val filter =
Filter(
kinds = listOf(ChatEvent.KIND, StreamMessageV2Event.KIND, SystemMessageEvent.KIND),
tags = mapOf("h" to listOf(groupId)),
limit = limit,
)
val messages =
ctx
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000)
.map { it.second }
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
.take(limit)
.map {
mapOf(
"id" to it.id,
"kind" to it.kind,
"author" to it.pubKey,
"created_at" to it.createdAt,
"content" to it.content,
)
}
Output.emit(mapOf("group_id" to groupId, "relay" to relay.url, "count" to messages.size, "messages" to messages))
return 0
}
}
/**
* `buzz attest AGENT [--kind K] [--after T] [--before T]` → signs a NIP-OA
* [OwnerAttestation] authorizing AGENT and prints the `auth` tag. Offline (the
* signature covers a hashed commitment, not an event) and needs a local private key.
*/
private suspend fun attest(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]"
val agentInput = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val agentHex =
decodePublicKeyAsHexOrNull(agentInput.trim())?.takeIf { it.isValid() }
?: return Output.error("bad_args", "invalid agent public key (npub or 64-char hex): $agentInput")
val kind = args.flag("kind")?.let { it.toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer") }
val after = args.flag("after")?.let { it.toLongOrNull() ?: return Output.error("bad_args", "after must be a unix time") }
val before = args.flag("before")?.let { it.toLongOrNull() ?: return Output.error("bad_args", "before must be a unix time") }
args.rejectUnknown("kind", "after", "before")
Context.open(dataDir).use { ctx ->
val keyPair = ctx.identity.keyPair()
if (keyPair.privKey == null) return Output.error("no_private_key", "attestation signing needs a local private key")
val conditions = AttestationConditions(kind = kind, createdAtBefore = before, createdAtAfter = after)
val attestation =
try {
OwnerAttestation.sign(agentHex, conditions, keyPair)
} catch (e: IllegalArgumentException) {
return Output.error("bad_args", e.message ?: "could not sign the attestation")
}
Output.emit(
mapOf(
"agent" to agentHex,
"owner" to attestation.ownerPubKey,
"conditions" to attestation.conditions.ifEmpty { null },
"sig" to attestation.sig,
"auth_tag" to attestation.toTag().toList(),
),
)
return 0
}
}
/**
* `buzz console [--relays R,R] [--timeout SECS]` → fetches my kind-44200 turn metrics
* (`#p` = me), NIP-44-decrypts them, and aggregates fleet + per-agent cost via the same
* [AgentFleetAggregator] the app's Agent Console uses.
*/
private suspend fun console(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relaysFlag = args.flag("relays")
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 15
args.rejectUnknown("relays", "timeout")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me)))
val decrypted =
ctx
.drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
.map { it.second }
.filterIsInstance<AgentTurnMetricEvent>()
.distinctBy { it.id }
.mapNotNull { e -> e.decryptOrNull(ctx.signer)?.let { (e.agentPubKey() ?: e.pubKey) to it } }
val metrics = AgentFleetAggregator.aggregate(decrypted)
Output.emit(
mapOf(
"total_cost_usd" to metrics.totals.costUsd,
"total_tokens" to metrics.totals.totalTokens,
"input_tokens" to metrics.totals.inputTokens,
"output_tokens" to metrics.totals.outputTokens,
"turns" to metrics.totalTurns,
"sessions" to metrics.totalSessions,
"agents" to metrics.agents.size,
"estimated" to metrics.hasUnreliableEstimates,
"breakdown" to
metrics.agents.map {
mapOf(
"agent" to it.agentPubKey,
"cost_usd" to it.totals.costUsd,
"tokens" to it.totals.totalTokens,
"turns" to it.turns,
"sessions" to it.sessions,
"models" to it.models.sorted(),
"last_activity" to it.lastActivity,
)
},
),
)
return 0
}
}
/** `buzz personas [--relays R,R] [--timeout SECS]` → lists my kind-30175 persona definitions. */
private suspend fun personas(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relaysFlag = args.flag("relays")
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 15
args.rejectUnknown("relays", "timeout")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me))
val personas =
ctx
.drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
.map { it.second }
.filterIsInstance<PersonaEvent>()
// Newest per addressable slug (replaceable): keep the latest for each d tag.
.groupBy { it.slug() }
.values
.mapNotNull { versions -> versions.maxByOrNull { it.createdAt } }
.sortedBy { it.personaOrNull()?.displayName ?: it.slug() ?: "" }
.map {
val content = it.personaOrNull()
mapOf(
"slug" to it.slug(),
"display_name" to content?.displayName,
"model" to content?.model,
"runtime" to content?.runtime,
"provider" to content?.provider,
)
}
Output.emit(mapOf("count" to personas.size, "personas" to personas))
return 0
}
}
/** The `--relays` set if given, else the account's outbox relays. */
private suspend fun relaysFor(
ctx: Context,
relaysFlag: String?,
) = relaysFlag
?.split(",")
?.mapNotNull { normalizeGroupRelay(it) }
?.toSet()
?: ctx.outboxRelays()
}

View File

@@ -0,0 +1,91 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Holds the NIP-OA [OwnerAttestation]s this device has *received* — one owner-signed
* authorization per agent key that lets that key publish in the owner's Buzz workspace
* without being enrolled as a relay member.
*
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an
* agent operator out-of-band): when the account whose pubkey equals a stored key
* authenticates (NIP-42) to a Buzz-dialect relay, the auth coordinator attaches the
* matching [OwnerAttestation.toTag] to the AUTH event, and the relay grants virtual
* membership while the owner stays a member.
*
* Keyed by the **agent** pubkey (the key the attestation authorizes). Only a
* [OwnerAttestation.verify]-passing attestation for that key should be stored, so the
* store never carries a credential the relay would reject.
*
* Like [BuzzRelayDialect] this is a process-wide singleton, and — for now — in-memory
* only: a held attestation is re-pasted after a process restart. Persisting it across
* launches (per-account, encrypted) is a follow-up.
*/
object BuzzHeldAttestations {
private val heldByAgent = MutableStateFlow<Map<HexKey, OwnerAttestation>>(emptyMap())
/** All held attestations, keyed by agent pubkey; UI can collect this. */
val flow: StateFlow<Map<HexKey, OwnerAttestation>> = heldByAgent
/** The attestation held for [agentPubKey], or null. */
fun attestationFor(agentPubKey: HexKey): OwnerAttestation? = heldByAgent.value[agentPubKey]
/**
* The `auth` tag to attach to [agentPubKey]'s NIP-42 AUTH event, or null when no
* verified attestation is held for that key.
*/
fun authTagFor(agentPubKey: HexKey): Array<String>? = attestationFor(agentPubKey)?.toTag()
/**
* Stores [attestation] as authorizing [agentPubKey]. The caller must have already
* confirmed `attestation.verify(agentPubKey)`; this is a CAS-loop put so concurrent
* writers don't clobber each other.
*/
fun put(
agentPubKey: HexKey,
attestation: OwnerAttestation,
) {
while (true) {
val current = heldByAgent.value
if (current[agentPubKey] == attestation) return
if (heldByAgent.compareAndSet(current, current + (agentPubKey to attestation))) return
}
}
/** Removes any attestation held for [agentPubKey]. */
fun remove(agentPubKey: HexKey) {
while (true) {
val current = heldByAgent.value
if (agentPubKey !in current) return
if (heldByAgent.compareAndSet(current, current - agentPubKey)) return
}
}
/** Test-only: clears all held attestations so unit tests don't leak state. */
fun clearForTesting() {
heldByAgent.value = emptyMap()
}
}

View File

@@ -0,0 +1,84 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Live "who is typing" state for Buzz workspace channels, fed by the ephemeral
* kind-20002 typing indicator (`TypingIndicatorEvent`). Buzz relays never store these
* (20000-29999), so this is pure in-memory live state — the exact analog of
* `ConcordCommunitySession.typing`, but keyed by the channel's `h` UUID because Buzz
* channels are relay-authoritative NIP-29 groups rather than encrypted planes.
*
* Shape: `channelId -> (typistPubKey -> lastHeartbeatUnixSecs)`. A typist is "active"
* while their last heartbeat is within [TYPING_STALE_SECS]; the UI ages them out on a
* timer. Mutations are lock-guarded because `LocalCache` consume runs on several relay
* reader threads and an unsynchronized check-then-act could drop a fresh heartbeat.
*
* Process-wide singleton like [BuzzRelayDialect] / `BuzzWorkspaceStates`.
*/
object BuzzTypingState {
private val lock = KmpLock()
private val typingByChannel = HashMap<String, HashMap<HexKey, Long>>()
private val mutableTyping = MutableStateFlow<Map<String, Map<HexKey, Long>>>(emptyMap())
/** `channelId -> (typist -> lastHeartbeatSecs)`; the UI collects this and ages entries out. */
val flow: StateFlow<Map<String, Map<HexKey, Long>>> = mutableTyping
/**
* Records a typing heartbeat from [typist] in [channelId] at [atSecs] (clamped to
* [nowSecs] so a future-dated peer can't wedge the freshness window), then prunes
* anything already stale. No-op for a stale-on-arrival heartbeat.
*/
fun record(
channelId: String,
typist: HexKey,
atSecs: Long,
nowSecs: Long,
) = lock.withLock {
val stamp = minOf(atSecs, nowSecs)
if (nowSecs - stamp > TYPING_STALE_SECS) return@withLock
val perChannel = typingByChannel.getOrPut(channelId) { HashMap() }
val prev = perChannel[typist]
if (prev == null || stamp > prev) perChannel[typist] = stamp
perChannel.entries.retainAll { nowSecs - it.value <= TYPING_STALE_SECS }
if (perChannel.isEmpty()) typingByChannel.remove(channelId)
mutableTyping.value = typingByChannel.mapValues { it.value.toMap() }
}
/** Test-only: clears all typing state so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
typingByChannel.clear()
mutableTyping.value = emptyMap()
}
/** A peer must heartbeat at least this often to stay "typing"; also the UI fade window. */
const val TYPING_STALE_SECS = 8L
/** Client-side throttle: send at most one typing heartbeat this often while composing. */
const val TYPING_HEARTBEAT_SECS = 4L
}

View File

@@ -56,6 +56,11 @@ class BuzzWorkspaceState {
var canvasNote: Note? = null
private set
private val canvasVersion = MutableStateFlow(0)
/** Bumps when [canvasNote] is replaced by a newer revision, so a canvas view re-reads it. */
val canvasUpdates: StateFlow<Int> = canvasVersion
/** Records a 40003 edit; keeps only the newest per target (last-write-wins by created_at). */
fun addEdit(
targetId: HexKey,
@@ -78,6 +83,7 @@ class BuzzWorkspaceState {
lock.withLock {
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
canvasNote = note
canvasVersion.value = canvasVersion.value + 1
}
}

View File

@@ -0,0 +1,199 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricPayload
import com.vitorpamplona.quartz.buzz.amTurnMetrics.TokenCounts
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AgentFleetAggregatorTest {
private val agentA = "a".repeat(64)
private val agentB = "b".repeat(64)
private fun turn(
agent: String,
session: String?,
seq: Long,
turn: TokenCounts? = null,
cumulative: TokenCounts? = null,
deltaReliable: Boolean = true,
ts: String = "2026-07-0${seq}T00:00:00Z",
harness: String = "goose",
model: String? = "claude",
) = agent to
AgentTurnMetricPayload(
harness = harness,
timestamp = ts,
model = model,
sessionId = session,
turnId = "turn-$seq",
turnSeq = seq,
turn = turn,
cumulative = cumulative,
deltaReliable = deltaReliable,
)
private fun counts(
input: Long? = null,
output: Long? = null,
total: Long? = null,
cost: Double? = null,
) = TokenCounts(inputTokens = input, outputTokens = output, totalTokens = total, costUsd = cost)
@Test
fun emptyYieldsEmpty() {
assertEquals(AgentFleetMetrics.EMPTY, AgentFleetAggregator.aggregate(emptyList()))
}
@Test
fun cumulativeIsTheSessionTotalNotTheSumOfCumulatives() {
// Three turns of one session; cumulative is the running total. Summing the
// cumulatives (100+250+400) would triple-count — the total is the LAST one, 400.
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10)),
turn(agentA, "s1", 2, cumulative = counts(total = 250, cost = 0.25)),
turn(agentA, "s1", 3, cumulative = counts(total = 400, cost = 0.40)),
),
)
assertEquals(400L, metrics.totals.totalTokens)
assertEquals(0.40, metrics.totals.costUsd, 1e-9)
assertEquals(3, metrics.totalTurns)
assertEquals(1, metrics.totalSessions)
assertFalse(metrics.hasUnreliableEstimates)
}
@Test
fun aMissingMiddleTurnStillGivesTheRightSessionTotal() {
// Turn 2 never arrived; cumulative-max still recovers the true session total.
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, "s1", 1, cumulative = counts(total = 100)),
turn(agentA, "s1", 3, cumulative = counts(total = 400)),
),
)
assertEquals(400L, metrics.totals.totalTokens)
}
@Test
fun withoutCumulativeItSumsTheTurnDeltas() {
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, "s1", 1, turn = counts(input = 10, output = 5, total = 15, cost = 0.01)),
turn(agentA, "s1", 2, turn = counts(input = 20, output = 8, total = 28, cost = 0.02)),
),
)
assertEquals(30L, metrics.totals.inputTokens)
assertEquals(13L, metrics.totals.outputTokens)
assertEquals(43L, metrics.totals.totalTokens)
assertEquals(0.03, metrics.totals.costUsd, 1e-9)
}
@Test
fun perFieldFallbackTakesCostFromDeltasWhenCumulativeOmitsIt() {
// cumulative reports tokens but not cost; cost must still come from the deltas.
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, "s1", 1, turn = counts(cost = 0.05), cumulative = counts(total = 100)),
turn(agentA, "s1", 2, turn = counts(cost = 0.07), cumulative = counts(total = 200)),
),
)
assertEquals(200L, metrics.totals.totalTokens) // from cumulative max
assertEquals(0.12, metrics.totals.costUsd, 1e-9) // from summed deltas
}
@Test
fun distinctSessionsSumTogether() {
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10)),
turn(agentA, "s2", 1, cumulative = counts(total = 300, cost = 0.30)),
),
)
assertEquals(400L, metrics.totals.totalTokens)
assertEquals(0.40, metrics.totals.costUsd, 1e-9)
assertEquals(2, metrics.totalSessions)
}
@Test
fun turnsWithNoSessionIdAreSeparateSingletons() {
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, null, 1, cumulative = counts(total = 100)),
turn(agentA, null, 2, cumulative = counts(total = 100)),
),
)
// Two sessionless turns must NOT collapse into one session (that would drop one).
assertEquals(2, metrics.totalSessions)
assertEquals(200L, metrics.totals.totalTokens)
}
@Test
fun perAgentBreakdownSortedByCostDescending() {
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10), model = "haiku"),
turn(agentB, "s2", 1, cumulative = counts(total = 900, cost = 0.90), model = "opus"),
),
)
assertEquals(2, metrics.agents.size)
assertEquals(agentB, metrics.agents[0].agentPubKey) // pricier agent first
assertEquals(0.90, metrics.agents[0].totals.costUsd, 1e-9)
assertEquals(setOf("opus"), metrics.agents[0].models)
assertEquals("2026-07-01T00:00:00Z", metrics.agents[1].lastActivity)
}
@Test
fun unreliableDeltaWithoutCumulativeFlagsEstimate() {
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, "s1", 1, turn = counts(total = 50, cost = 0.05), deltaReliable = false),
),
)
assertEquals(50L, metrics.totals.totalTokens)
assertTrue(metrics.hasUnreliableEstimates)
}
@Test
fun cumulativePresenceMakesUnreliableDeltaIrrelevant() {
// deltaReliable=false but cumulative is present → we use cumulative, no estimate flag.
val metrics =
AgentFleetAggregator.aggregate(
listOf(
turn(agentA, "s1", 1, turn = counts(total = 999), cumulative = counts(total = 50), deltaReliable = false),
),
)
assertEquals(50L, metrics.totals.totalTokens)
assertFalse(metrics.hasUnreliableEstimates)
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNull
class BuzzHeldAttestationsTest {
private val agent = "a".repeat(64)
private val owner = "b".repeat(64)
private val attestation = OwnerAttestation(ownerPubKey = owner, conditions = "kind=40002", sig = "c".repeat(128))
@AfterTest
fun tearDown() = BuzzHeldAttestations.clearForTesting()
@Test
fun emptyStoreYieldsNoTag() {
assertNull(BuzzHeldAttestations.attestationFor(agent))
assertNull(BuzzHeldAttestations.authTagFor(agent))
}
@Test
fun heldAttestationSurfacesAsItsAuthTag() {
BuzzHeldAttestations.put(agent, attestation)
assertEquals(attestation, BuzzHeldAttestations.attestationFor(agent))
// The tag attached to the agent's AUTH is exactly the attestation's ["auth", …] tag.
assertContentEquals(attestation.toTag(), BuzzHeldAttestations.authTagFor(agent))
}
@Test
fun removeClearsTheHeldAttestation() {
BuzzHeldAttestations.put(agent, attestation)
BuzzHeldAttestations.remove(agent)
assertNull(BuzzHeldAttestations.authTagFor(agent))
}
@Test
fun oneAgentsAttestationDoesNotLeakToAnother() {
BuzzHeldAttestations.put(agent, attestation)
assertNull(BuzzHeldAttestations.authTagFor("d".repeat(64)))
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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.commons.model.buzz
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BuzzTypingStateTest {
private val channel = "chan-1"
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
@BeforeTest fun setup() = BuzzTypingState.clearForTesting()
@AfterTest fun teardown() = BuzzTypingState.clearForTesting()
@Test
fun recordsAFreshHeartbeat() {
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100)
assertEquals(setOf(alice), BuzzTypingState.flow.value[channel]?.keys)
}
@Test
fun staleOnArrivalIsDropped() {
// atSecs is older than nowSecs by more than the window → never recorded.
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100 + BuzzTypingState.TYPING_STALE_SECS + 1)
assertNull(BuzzTypingState.flow.value[channel])
}
@Test
fun aNewerHeartbeatWins_andStaleTypistsArePruned() {
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100)
BuzzTypingState.record(channel, bob, atSecs = 101, nowSecs = 101)
assertEquals(setOf(alice, bob), BuzzTypingState.flow.value[channel]?.keys)
// A heartbeat far in the future ages both prior ones out of the window; only the
// fresh one survives, and the newer stamp for bob is what's kept.
val later = 101 + BuzzTypingState.TYPING_STALE_SECS + 5
BuzzTypingState.record(channel, bob, atSecs = later, nowSecs = later)
assertEquals(setOf(bob), BuzzTypingState.flow.value[channel]?.keys)
assertEquals(later, BuzzTypingState.flow.value[channel]?.get(bob))
}
@Test
fun futureDatedHeartbeatIsClampedToNow() {
// A peer claiming a wildly-future createdAt must not become un-expirable.
BuzzTypingState.record(channel, alice, atSecs = 10_000, nowSecs = 100)
assertEquals(100L, BuzzTypingState.flow.value[channel]?.get(alice))
}
@Test
fun channelsAreIsolated() {
BuzzTypingState.record("c1", alice, atSecs = 100, nowSecs = 100)
BuzzTypingState.record("c2", bob, atSecs = 100, nowSecs = 100)
assertEquals(setOf(alice), BuzzTypingState.flow.value["c1"]?.keys)
assertEquals(setOf(bob), BuzzTypingState.flow.value["c2"]?.keys)
assertTrue(BuzzTypingState.flow.value.size == 2)
}
}

View File

@@ -141,15 +141,76 @@ kind-filtered, so they can never receive Buzz kinds by accident):
replaceably, the rest store as queryable regular events, and ephemeral signals
(typing 20002, observer 24200, huddle reactions 24810, pairing 24134) mark the
dialect but are deliberately not persisted.
- The group-chat REQ builders (`RelayGroupFilterBuilders`) request 40002/40003/40008/40099
**unconditionally** alongside the NIP-29 kinds (they match nothing on a vanilla relay).
- The group-chat REQ builders (`RelayGroupFilterBuilders`) request the whole Buzz timeline
set — 40002/40003/40008/40099, canvas 40100, forum 45001-45003, agent jobs 43001-43006,
huddle lifecycle 48100-48103 — **unconditionally** alongside the NIP-29 kinds (they match
nothing on a vanilla relay; all are `h`-scoped so the same `#h` group REQ returns them).
A dialect-gated version was reverted: gating advanced history cursors past ranges that
had been queried without the Buzz kinds, permanently skipping older workspace messages.
- Kind-aware chat rendering (`RenderBuzzNotes` + `ChatMessageCompose`): 40002 as text,
40003 as an edit overlay, 40099 as a system row, **40008 diffs** as a monospace scrollable
block with a repo/commit/file header, **agent-job (43xxx) + huddle (48xxx) lifecycle** as
centered system narration (huddles carry JSON content, so they must not fall through to a
raw text bubble), and **forum votes (45002)** as a glyph line. Forum posts stay plain text.
- Dialect marking fires only off a **verified** event (`markBuzzIfVerified`), so an
unverifiable frame from a hostile/buggy relay can't flip what the composer sends.
Not yet wired: kind-aware rendering of 40002/40099/diff rows in the chat UI, the
composer switch (write 40002 on Buzz relays), and NIP-42-at-connect workspace sessions.
### NIP-OA agent auth at connect — implemented
`commons/.../model/buzz/BuzzHeldAttestations.kt` holds the attestations this device has
*received* (keyed by the agent pubkey they authorize; only `verify`-passing ones are
stored). When an account whose pubkey matches a held attestation AUTHs (NIP-42) to a
**Buzz-dialect** relay, `AuthCoordinator.buzzAugmented` appends the owner-signed `auth`
tag to that account's AUTH event — and only that account's, never the Concord stream-key
AUTHs that share the template — so an un-enrolled agent key gets virtual membership while
its owner stays a member. The paste/hold + owner-side issue flows both live in
`AgentAttestationScreen`. (Store is in-memory for now; persisting per-account is a follow-up.)
The **canvas (40100)** now has a viewer: `BuzzWorkspaceState` exposes a `canvasUpdates`
flow and `amethyst/.../buzz/BuzzCanvasScreen` (`Route.BuzzCanvas`) renders the newest
markdown, reached from a Dashboard action in the relay-group top bar that appears only on
a Buzz relay once a canvas has arrived.
The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode
(`editBuzzMessage`/`clearBuzzEdit`) whose next send publishes a 40003 targeting the
original (minimal, mirroring Buzz's `build_edit`); an "Edit" action gated to the user's
own 40002 messages threads to the composer through the shared chat feed via an optional
`onWantsToEditBuzz` callback (default-null, so no other chat surface is affected), and
`EditFieldRow` shows an editing banner. This closes the render↔create loop (edit overlays
already rendered).
The **social/moderation kinds work through the shared chat action sheet with no Buzz-specific
wiring**, because Buzz's relay accept-list (`buzz-core/src/kind.rs` `ALL_KINDS`) deliberately
includes the standard Nostr kinds: **reactions** (NIP-25 kind 7 — `requires_h_channel_scope`
is false, so no h-tag needed), **deletions** (NIP-09 kind 5, plus NIP-29 group delete 9005/9008),
**reports** (NIP-56 kind 1984, queued to the tenant's `moderation_reports`), and **custom emoji**
(10030/30030). Amethyst's `ChatMessageActionSheet` already produces these and they are accepted,
stored, and served by the Buzz relay. The one social action NOT in Buzz's accept-list is **zaps**
(no 9734/9735) — but a zap is a Lightning payment whose receipt is published by the LN provider to
the author's relays, so zapping still works; only the receipt isn't stored on the Buzz relay.
**Live typing indicators (kind 20002)** are wired: `commons/.../model/buzz/BuzzTypingState`
is a process-wide, lock-guarded `channel -> typist -> heartbeat` registry (6 unit tests);
`LocalCache` records each ephemeral 20002 into it (still no feed row), the open channel's
live tail requests 20002 (`RELAY_GROUP_OPEN_TAIL_KINDS`, scoped to the room on screen),
`Account.sendBuzzTyping` fires a throttled heartbeat from the composer, and
`BuzzTypingIndicator` renders an animated "… is typing" row above the input. This matches
Concord's typing feature. (`requires_h_channel_scope(20002)` is false on the relay, but
Amethyst still `h`-scopes the send + subscription so it never leaks across channels.)
**Navigation identity — a "Workspaces" tab.** `NavBarItem.BUZZ` (→ `Route.BuzzWorkspaces`)
is a pinnable bottom-nav destination; `amethyst/.../buzz/BuzzWorkspacesScreen` is a hub that
filters the joined relay groups to Buzz-dialect relays and leads with an Agent-Console hero
card. Buzz workspaces still ALSO appear in the generic Relay Groups list (they are NIP-29
groups); the tab is a Buzz-branded lens over the same data, not a separate store.
Still not wired: the forum/job/huddle/DM message composers — each a send flow for a
richer kind, and jobs/huddles are among the schema-inferred kinds, so they want Buzz-side
confirmation before UI. **Presence (kind 20001)** is accepted by Buzz but collides with
Amethyst's `GeohashPresenceEvent` in `EventFactory` (20001 is registered as geohash
presence), so it needs disambiguation before it can render — unlike typing (20002), which
has no such collision. A **workspace→channels shell** (Concord-style channel sidebar) and
per-account persistence of held attestations remain follow-ups.
## Owner Attestation (NIP-OA) — implemented
@@ -175,3 +236,36 @@ An encrypted, durable record of one agent turn's token usage and cost, published
agent to its owner. `content` is a NIP-44 v2 ciphertext of `AgentTurnMetricPayload`
(camelCase) between the agent (author + `agent` tag) and the owner (`p` tag); either
party can decrypt. See `amTurnMetrics/AgentTurnMetricEvent.kt`.
## Agent-owner console (commons + amethyst) — implemented
The owner-facing dashboard over their AI-agent fleet. Reachable from Settings → App →
"Agent console" (`Route.AgentConsole`), three tabs:
- **Costs** — decrypted NIP-AM turn metrics (`kind:44200`, `p` = owner) rolled up by the
pure `commons/.../model/buzz/AgentFleetMetrics.kt::AgentFleetAggregator`. Cost/token
semantics are the aggregator's, not the UI's: per `(agent, session)` the **max
cumulative** per field is the authoritative session total (robust to dropped turns),
falling back per-field to **summing the `turn` deltas** only when no cumulative exists;
a delta-sum touching a `deltaReliable == false` turn flags the fleet total estimated.
Ten aggregator tests pin these (cumulative-not-summed, missing-turn recovery, per-field
mixing, sessionless singletons, unreliable flag).
- **Personas** — the owner's plaintext persona definitions (NIP-AP `kind:30175`).
- **Observer** — a **live** subscription to ephemeral telemetry frames (NIP-AO
`kind:24200`, `p` = owner) via `subscribeAsFlow`, decrypted `frame:telemetry` only,
newest-first, bounded to a 200-row ring. Opened only while the tab is on screen
(`DisposableEffect`) because relays and `LocalCache` never store observer frames.
`AgentConsoleViewModel` fetches 44200 + 30175 from the Buzz-dialect relays plus the
owner's outbox set, lets `LocalCache` consume them, decrypts with the owner signer
(cached by event id), and re-derives the aggregate.
A **"Attest" FAB** opens `AgentAttestationScreen` — owner-side **NIP-OA issuance**. The
owner enters an agent pubkey (npub/hex) and optional `AttestationConditions` (kind, and/or
`created_at` before/after bounds); `OwnerAttestation.sign(agent, conditions, ownerKey)`
produces the signed `auth` tag, shown for copy so the owner hands it to the agent operator
out-of-band. It is entirely offline (nothing is published) and needs the owner's **raw
private key** — accounts on a NIP-46 bunker / NIP-55 external signer get an explanation
instead of the form, because the signature covers a hashed commitment rather than an event.
Read-only v1 for the tabs — persona editing is the remaining follow-up.