mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user