From 12aa9fe9545ed8332ff30a3714d23f9fedc5cb67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 05:01:25 +0000 Subject: [PATCH] feat(cli): first-class 'amy buzz' commands for block/buzz workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin assembly over quartz + commons (no new protocol in cli/): - buzz post RELAY GID — 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 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- cli/README.md | 16 + cli/ROADMAP.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 9 + .../amethyst/cli/commands/BuzzCommands.kt | 285 ++++++++++++++++++ 4 files changed, 311 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt diff --git a/cli/README.md b/cli/README.md index 2ea5a7bd51..1f6760d397 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 ` | 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 diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index ee3998d54c..4fbe280968 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 2f0ac667fa..1c3354ac84 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt new file mode 100644 index 0000000000..576472b5d3 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt @@ -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 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, + ): 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 ` → publishes a kind-40002 stream message with an `h` tag. */ + private suspend fun post( + dataDir: DataDir, + rest: Array, + ): Int { + val usage = "buzz post RELAY GID " + 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, + ): 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, + ): 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, + ): 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() + .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, + ): 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() + // 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() +}