Compare commits

...

24 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
Claude
c79b795477 fix(buzz): address adversarial review findings (channel-swap, verified marks, engram tombstone)
Three-reviewer pass over the branch. Fixes, most-severe first:

HIGH — channel-instance swap froze open screens. The subclass-upgrade design
replaced a group's RelayGroupChannel with a Buzz-typed instance on dialect
discovery, but screens/feeds/composers capture the instance for life, so all of
them kept rendering the orphan (frozen feed; composer stuck on kind 9). Removed
BuzzWorkspaceChannel entirely; RelayGroupChannel is a single stable type again.
Buzz-only overlay state (edit + canvas) now lives in BuzzWorkspaceStates, a
registry keyed by the channel UUID — dialect discovery no longer touches object
identity. The swap also silently dropped all relay-signed state (name, members,
admin status, pins, threads) and demoted the confirmed host; gone with the swap.

HIGH — wrong thread-root got messages relay-rejected AFTER the draft was
destroyed. A reply to a direct reply derived root=parent (buzzThreadRoot null on
a collapsed reply), which Buzz's ancestry validator rejects. Fallback is now
buzzThreadRoot() ?: buzzThreadReply() ?: parent.id. Also: minichat (kind 1111)
replies in Buzz channels are relay-rejected, so they're gated off for Buzz relays.

HIGH — pre-create defeated stray-redirect and marked the dialect off unverified
input. consumeBuzzTimelineEvent no longer pre-creates the channel; attachment
goes through the shared NIP-29 path (with its stray protection), and the dialect
is marked only off a VERIFIED event (markBuzzIfVerified) — a hostile relay can no
longer flip what the composer sends.

MEDIUM — engram tombstone divergence: a memory body missing `value` decoded as a
null tombstone (a deletion) where Buzz rejects it. `value` is now required (no
default); added NIP-AE slug-grammar validation. Pinned by test.

MEDIUM — unbounded edit overlay: pruneOldMessagesChannel now prunes overlay
entries for reaped messages. Overlay is keyed by channel id so own offline edits
(null relay) apply too.

Also: 40002 inbound reply linkage in computeReplyTo (we emit thread markers we
couldn't read); registered-but-unhandled 40901/40902/48001 now consumed;
unconditional Buzz-kind widening (kills the history-cursor-skip); stream mention
dedup; persona empty-list omission for content-hash parity; thread-marker
positional guard; edit-note-loading blank-row fallback.

Full amethyst unit suite + affected quartz suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:00:52 +00:00
Claude
6bc16314e8 test(amethyst): update open-tail kind expectation for the Buzz bootstrap
The open-channel tail deliberately carries the Buzz timeline kinds now (the
dialect-detection bootstrap); pin the new contract in the existing builder test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 22:24:27 +00:00
Claude
c85f51c40a refactor(buzz): typed thread/p tags in composer + dialect bootstrap + live join/leave proof
Review feedback: the 40002 composer branch hand-rolled raw arrayOf tags. Now:

- New shared `buzz/threading` package: `buzzThread(root, parent)` builder verb
  emitting Buzz's exact thread_tags wire form (["e",root,"","root"] +
  ["e",parent,"","reply"], collapsing when parent==root; the empty relay slot
  is deliberate — MarkedETag.assemble's arrayOfNotNull would slide the marker
  into the relay slot) and `buzzThreadRoot()/buzzThreadReply()` positional
  readers. The forum verbs now delegate to it (streams and forum comments share
  thread_tags in buzz-sdk), and the composer uses buzzThread + the typed
  pTag(PTag(...)) verb instead of raw arrays.

- Dialect bootstrap fix: the single-group open-channel REQ now always includes
  the Buzz timeline kinds. Without this, an undiscovered Buzz relay was a
  chicken-and-egg: fleet subs only widen after BuzzRelayDialect marks the
  relay, but the mark comes from consuming a Buzz kind no filter asked for.
  Opening a channel is explicit one-group intent, so the wider ask is cheap and
  matches nothing on vanilla relays; fleet-wide subs stay dialect-gated
  (both behaviors pinned in BuzzTimelineKindsTest). The live relay's NIP-11
  ("Buzz Relay", supported_extensions=[nip-er,nip-pl]) is a future
  connect-time marker.

- Live proof of the full workspace lifecycle against the running Buzz relay
  (BuzzRelayLiveInteropTest, 3/3 green): discover the channel via its
  relay-signed 39000 (queryable by #d), join with NIP-29 kind-9021 from a
  second member, post a 40002 after joining, and leave with kind-9022 — the
  exact Quartz events Amethyst's group UI sends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 22:16:39 +00:00
Claude
54ecae50af feat(amethyst): render Buzz channels natively + compose kind-40002 messages
Completes the visible half of Buzz workspace support:

- Composer: BuzzWorkspaceChannel branch (checked before its RelayGroupChannel
  parent) sends the native kind-40002 stream message with the group's h tag,
  Buzz thread markers on replies (["e",root,"","root"] + ["e",parent,"","reply"],
  collapsing when parent is the root — mirrors thread_tags in buzz-sdk) and a
  p notify to the parent author, with the same hashtag/url/quote/emoji/imeta
  enrichment as every other channel type.
- Rendering: kind-40099 system messages render as centered system lines (like
  NIP-28 admin rows) from the relay-signed payload; kind-40003 edits render as
  an overlay — the newest edit's content replaces the stale original with an
  "(edited)" marker, recomposing via the channel's editUpdates flow.
- LocalCache correction pinned by test: edits are overlays, never timeline rows
  (Buzz's own CHANNEL_TIMELINE_CONTENT_KINDS excludes 40003) — a 40003 is
  stored and recorded in the channel's edit map but no longer attached to the
  timeline, so it can't render as a duplicate message.

All Buzz unit tests green; app compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 21:21:38 +00:00
Claude
7903834efd feat(commons): Buzz workspaces as NIP-29 groups in the Buzz dialect
Surfaces block/buzz workspace channels inside the existing NIP-29 relay-group
experience — one group model, dialect-aware rendering, zero impact on vanilla
groups (kind-filtered feeds can never receive Buzz kinds by accident).

- BuzzRelayDialect (commons): per-relay capability registry. Event-shape
  detection — the first Buzz-only kind consumed from a relay marks it; vanilla
  relays never serve those kinds, so no false positives. NIP-11 marking can be
  layered on later.
- BuzzWorkspaceChannel (commons): sibling of RelayGroupChannel (now `open`)
  holding Buzz-only channel state: the kind-40003 edit overlay (never render
  superseded text as current) and the newest kind-40100 canvas. Kind-9 chat and
  kind-40002 stream messages share ONE timeline per group so mixed-dialect
  conversations stay whole.
- LocalCache: consumes every registered Buzz kind (previously all fell into the
  "Event Not Supported" branch). Timeline kinds attach to the group's channel,
  materialized dialect-aware with an in-place upgrade (note migration) when the
  dialect is discovered after the channel was first created as plain NIP-29.
  Addressables store replaceably; the rest store as queryable regular events;
  ephemeral signals (typing 20002, observer 24200, huddle reaction 24810,
  pairing 24134) mark the dialect but are deliberately not persisted.
- RelayGroupFilterBuilders: group-chat REQs widen their timeline kind set with
  40002/40003/40008/40099 only for marked relays; vanilla NIP-29 REQs unchanged.

Tests cover dialect detection + materialization, the plain-channel upgrade with
timeline migration, newest-edit-wins overlay ordering, and that filter builders
extend kinds only on marked relays. Full amethyst unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 20:58:11 +00:00
Claude
d7125b8b08 test(quartz): live interoperability test against a running Buzz relay
Adds an env-gated jvmTest (BUZZ_RELAY_WS / BUZZ_MEMBER_SK / BUZZ_OWNER_SK; CI
skips it) that drives the real block/buzz relay booted from its own compose +
cargo build, with members enrolled via buzz-admin. Verified green against the
running relay:

- NIP-42 member auth, and NIP-OA/NIP-AA agent auth: a brand-new un-enrolled
  agent key authenticates using only our owner-signed `auth` tag — the relay's
  NIP-OA membership fallback accepts the Quartz-produced attestation.
- Channel lifecycle: kind:9007 create via the existing NIP-29 CreateGroupEvent
  (Buzz channels are NIP-29 groups), kind:40002 publish, REQ round-trip with a
  byte-identical echo (same id), EOSE.

Interop lessons encoded in the test + README: tenancy is host-bound (connect
with the bound Host or the WS upgrade 404s), `h` values must parse as UUIDs,
and the channel row must exist before channel-scoped kinds are accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 20:17:58 +00:00
Claude
f5d22ce720 feat(quartz): implement the full Buzz-custom protocol surface (~77 event kinds)
Adds Quartz-native models for the entire block/buzz custom kind space, each as
an idiomatic per-NIP package (Event + KIND companion + tags/ + write-DSL +
read accessors, mirroring nip88Polls) and registered in EventFactory. Standard
NIP-29/34/51/42/43 kinds Buzz reuses are left to their existing Quartz classes.

Coverage:
- Agent identity: Persona 30175, Team 30176, Managed Agent 30177, Agent Profile 10100
- Agent telemetry (encrypted): Observer 24200, Engram 30174 (HMAC d-tag derivation
  pinned to Buzz reference vectors), Turn Metric 44200 (prior commit)
- Workspace overlays: Reminders 30300, Push Lease 30350, DM Visibility 30622,
  Workspace Profile 9033, Identity Archival 9035/9036/8002/8003/13535,
  Channel Window 39005/39006, relay admin 9030-9032, moderation 9040-9044, 42000
- Messaging/collab: stream 40002-40100 (+sidecars), DMs 41001/41010-41012,
  jobs 43001-43006, forum 45001-45003, workflow 30620/46001-46031,
  notifications 44100/44101, presence 20001/20002, huddles 24810/48100-48106,
  pairing 24134, audit 48001, media 49001, read-state (NIP-RS helpers on 30078)

All schemas confirmed against the authoritative Rust in a local block/buzz
checkout (buzz-core / buzz-sdk / buzz-relay), not the outdated prose NIPs.
Kinds only reserved-but-unbuilt in Buzz (jobs, workflow lifecycle, some
stream/sidecar/audit kinds) are modeled tolerantly and flagged in KDoc + README.

Kind conflicts with existing Amethyst classes are implemented but deliberately
NOT registered in EventFactory (incumbent keeps dispatch): 9041 (GoalEvent),
20001 (GeohashPresenceEvent), 39005 (GroupPinnedEvent), plus 49001 (Buzz marks
it non-wire) and 30078 read-state (reuses AppSpecificDataEvent).

156 Buzz tests pass; full quartz jvmTest green (no regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 19:54:05 +00:00
Claude
1757ef4513 feat(quartz): restructure Buzz to per-event layout + Agent Turn Metric (NIP-AM)
Addresses review feedback on the initial Buzz commit:

- Drop the central `BuzzKinds` registry (un-idiomatic). Each event now
  declares its own `const val KIND` on its companion and is wired into
  `EventFactory`, matching the nip88Polls-style per-NIP layout used across
  Quartz (Event + tags/ + TagArrayBuilderExt write-DSL + TagArrayExt readers).

- Implement Agent Turn Metric (NIP-AM, kind:44200): an encrypted per-turn
  token-usage/cost record published by an agent to its owner. content is a
  NIP-44 v2 ciphertext of AgentTurnMetricPayload (camelCase), between the
  agent (author + `agent` tag) and owner (`p` tag); either party decrypts.

- Verify against a vector generated by Buzz's OWN code rather than a
  transcribed schema: a small generator on the real buzz-core emits a signed
  44200 event with deterministic keys; AgentTurnMetricVectorTest dispatches it
  through EventFactory, NIP-44-decrypts it, and asserts the payload — proving
  end-to-end interop. Fixture + generator committed under jvmTest resources.

Schemas confirmed against buzz-core/src/agent_turn_metric.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 19:04:15 +00:00
Claude
a54c808763 feat(quartz): add Buzz protocol kind registry + Owner Attestation (NIP-OA)
Introduces a top-level `buzz` package modelling the block/buzz protocol in
Quartz. Buzz is a NIP-29-family relay-group workspace (relay is the source of
truth, plaintext content, server-side membership) that layers an agent +
workspace vocabulary on top, so this package models only the Buzz-custom
extensions and reuses the existing NIP-29/34/51/42/43 classes for the standard
kinds.

- BuzzKinds: the full kind registry mirroring buzz-core `kind.rs`, annotated
  with where each standard kind already lives in Quartz.
- Owner Attestation (NIP-OA): the owner-signed `auth` tag that lets agents act
  as first-class members. Commitment = SHA-256("nostr:agent-auth:" + agent +
  ":" + conditions), BIP-340 Schnorr-signed by the owner; conditions grammar
  (kind=/created_at</created_at>, canonical decimals, u16/u32 bounds) matches
  buzz-sdk `nip_oa.rs` exactly.
- Tests pin Buzz's own published known-answer vector as a cross-implementation
  compliance check: our preimage hash equals Buzz's SHA-256 and our verifier
  accepts Buzz's reference signature.

Confirmed against the authoritative Rust (buzz-core/buzz-sdk), not the prose
NIP drafts, which lag the implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 18:47:12 +00:00
307 changed files with 20883 additions and 9 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

@@ -28,6 +28,9 @@ import com.vitorpamplona.amethyst.Amethyst
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
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
@@ -50,6 +53,82 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.erReminders.EventReminderEvent
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.HuddleGuidelinesEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentitiesListEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchivedIdentityEvent
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.managedAgents.ManagedAgentEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationBanEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
import com.vitorpamplona.quartz.buzz.plPushLease.PushLeaseEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminChangeRoleEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageBookmarkedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessagePinnedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalGrantedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCancelledEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
@@ -1257,6 +1336,16 @@ object LocalCache : ILocalCache, ICacheProvider {
(eTagTargets + qTagTargets).mapNotNull { checkGetOrCreateNote(it) }
}
is StreamMessageV2Event -> {
// Buzz threads 40002s with marked root/reply e-tags (thread_tags in
// buzz-sdk). Link both so inbound replies render their quote bubble —
// we emit these markers on send, so we must also read them.
listOfNotNull(
event.tags.buzzThreadRoot(),
event.tags.buzzThreadReply(),
).distinct().mapNotNull { checkGetOrCreateNote(it) }
}
else -> {
emptyList()
}
@@ -1986,6 +2075,82 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/**
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
* what the composer sends (40002 vs kind 9) and how new channels on the relay are
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
* The note-has-event check is the same verification gate the attach path uses.
*/
private fun markBuzzIfVerified(
event: Event,
relay: NormalizedRelayUrl?,
) {
if (relay != null && getNoteIfExists(event.id)?.event != null) {
BuzzRelayDialect.mark(relay)
}
}
/**
* Consume + channel-attach for Buzz workspace timeline kinds (stream messages,
* diffs, system rows, forum posts, job cards, huddle lifecycle). These kinds only
* exist on `block/buzz` relays, so their (verified) arrival IS the dialect
* detection. Attachment goes through the SAME shared NIP-29 path kind-9 chat uses
* — including its stray-redirect protection — so mixed vanilla/Buzz conversations
* share one timeline and non-host strays never mint phantom channels.
*/
private fun consumeBuzzTimelineEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
attachToRelayGroupIfScoped(event, relay)
}
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
private fun consumeBuzzRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
}
private fun consume(
event: StreamMessageEditEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an
// earlier message's content, never a row of its own. Store it and record the
// overlay (keyed by the channel's UUID, so own sends with no provenance relay
// land too), but do NOT attach it to the timeline.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.editedMessage() ?: return@also
val channelId = event.channel() ?: return@also
val editNote = getOrCreateNote(event.id)
if (editNote.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).addEdit(target, editNote)
}
}
private fun consume(
event: CanvasEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// The canvas is the channel's single living document, not a chat row: track
// only the newest revision as overlay state, never attach it to the timeline.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val channelId = event.channel() ?: return@also
val note = getOrCreateNote(event.id)
if (note.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).updateCanvas(note)
}
}
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
@@ -3040,6 +3205,13 @@ object LocalCache : ILocalCache, ICacheProvider {
channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS)
}
// A Buzz workspace's edit/canvas overlay is keyed off the channel id, outside
// `notes`, so the top-N reap never touches it. Drop overlay entries whose target
// message was just pruned, else they pin the edit note + author forever.
if (channel is RelayGroupChannel) {
BuzzWorkspaceStates.getIfExists(channel.groupId.id)?.pruneEdits(channel.notes.keys())
}
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
@@ -4407,6 +4579,107 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
// ------------------------------------------------------------------
// Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29).
// Timeline kinds attach into the group's BuzzWorkspaceChannel; the
// rest are stored for query/state. Kinds 9041/20001/39005/49001 are
// absent on purpose: their numbers belong to GoalEvent,
// GeohashPresenceEvent, GroupPinnedEvent and a non-wire audit kind.
// ------------------------------------------------------------------
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is CanvasEvent -> consume(event, relay, wasVerified)
is ForumPostEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is ForumCommentEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is ForumVoteEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
// Buzz addressable/replaceable state.
is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DmVisibilityEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
// Buzz store-only regular kinds (queryable state; no timeline row yet).
is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// Buzz relay-signed sidecars and audit projections: store-only, queryable.
is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// 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 -> {
// 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
// pairing before any workspace relationship is established.
is PairingEvent -> false
is PollEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
attachToRelayGroupIfScoped(event, relay)

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,6 +56,11 @@ 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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
@@ -65,8 +70,13 @@ 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
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
@@ -104,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.
@@ -129,6 +142,19 @@ fun ChatroomMessageCompose(
RenderChatClip(baseNote, accountViewModel, nav)
} else if (event is ChannelCreateEvent || event is ChannelMetadataEvent) {
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
} 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,
@@ -145,6 +171,7 @@ fun ChatroomMessageCompose(
onHighlightFinished,
groupPosition,
previousNoteId,
onWantsToEditBuzz,
)
}
}
@@ -175,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,
@@ -305,6 +333,7 @@ fun NormalChatNote(
onDismiss = onDismiss,
accountViewModel = accountViewModel,
nav = nav,
onWantsToEditBuzz = onWantsToEditBuzz,
)
},
reactionsRow =
@@ -582,7 +611,17 @@ fun NoteRow(
note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
else -> {
// Buzz channels overlay kind-40003 edits on their messages: when one
// exists, render the newest edit's content instead of the stale
// original. Null for every non-Buzz chat surface.
val buzzEdit = observeBuzzEdit(note)
if (buzzEdit != null) {
RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav)
} else {
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
}
}
}
}
}

View File

@@ -0,0 +1,268 @@
/*
* 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.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
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
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
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
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
/**
* Observes the newest kind-40003 edit overlaying [note], recomposing when new edits
* arrive. Returns null when the message is unedited or has no channel scope.
*
* Resolution goes through [BuzzWorkspaceStates] keyed by the note's `h` channel id
* (a Buzz UUID) rather than any channel object: the state exists independently of
* when — or whether — the channel materialized, so a row composed before the first
* edit arrived still starts rendering overlays the moment one lands.
*/
@Composable
fun observeBuzzEdit(note: Note): Note? {
val channelId = remember(note) { note.event?.groupId() } ?: return null
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Subscribing to the version counter is what re-runs editFor on new arrivals.
val version by state.editUpdates.collectAsState()
return remember(note, version) { state.editFor(note.idHex) }
}
/**
* A Buzz stream message whose content has been superseded by a kind-40003 edit:
* renders the NEWEST edit's content (never the stale original) plus an "(edited)"
* marker, mirroring Buzz's own last-write-wins presentation.
*/
@Composable
fun RenderBuzzEditedNote(
note: Note,
editNote: Note,
canPreview: Boolean,
innerQuote: Boolean,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
// The edit note may still be loading; fall back to the original rendering rather
// than committing to an edited branch that would show a blank row.
val content = editNote.event?.content
if (content == null) {
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
return
}
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
Column {
TranslatableRichTextViewer(
content = content,
canPreview = canPreview,
quotesLeft = if (innerQuote) 0 else 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
authorPubKey = note.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
Text(
text = stringRes(R.string.buzz_message_edited),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
/**
* A Buzz kind-40099 system message ("X joined", "channel created", "topic changed"):
* narrates the room rather than speaking in it, so it renders as a centered system
* line like the NIP-28 admin events, from the relay-signed JSON payload.
*/
@Composable
fun RenderBuzzSystemMessage(note: Note) {
val event = note.event as? SystemMessageEvent ?: return
// Relay-emitted machine text (join/leave/topic); shown as-is rather than through
// string resources — the payload vocabulary is Buzz's, not ours to translate yet.
val text =
remember(event) {
val payload = event.payload()
when (payload?.type) {
"topic_changed" -> payload.topic?.let { "topic: $it" } ?: "topic changed"
"purpose_changed" -> payload.purpose?.let { "purpose: $it" } ?: "purpose changed"
else -> payload?.type?.replace('_', ' ')
} ?: event.content.take(120)
}
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,25 @@
*/
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
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -85,6 +104,58 @@ val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS
/** Timeline kinds shown in a group's chat — chat messages and polls. */
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, 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(
StreamMessageV2Event.KIND,
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)
@@ -146,7 +217,7 @@ fun buildRelayGroupJoinedChatTailFilters(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
since = sinceEpoch,
),
@@ -162,7 +233,7 @@ fun buildRelayGroupOpenChatTailFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_OPEN_TAIL_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
@@ -185,7 +256,7 @@ fun buildRelayGroupHistoryFilters(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
until = until,
limit = limit,

View File

@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
@@ -68,6 +69,11 @@ 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
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
@@ -85,6 +91,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -156,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
@@ -264,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
@@ -525,7 +557,12 @@ open class ChannelNewMessageViewModel :
// channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/
// hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message,
// not the raw text — so replying in a thread never silently drops any of them.
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
// Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's
// registry — a minichat comment sent to a workspace channel would be refused
// by the relay AFTER the composer already cleared. Buzz replies always go
// through the 40002 branch with its thread markers instead.
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
if (minichatParent != null) {
return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) {
if (channel is RelayGroupChannel) {
@@ -676,6 +713,56 @@ 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
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"],
// collapsing to a single "reply" when the parent IS the root — mirrors
// thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent
// author. The dialect check comes from BuzzRelayDialect (marked off
// verified Buzz events), not a channel subtype: channel instances are
// captured by screens for their whole life, so the dialect must be
// able to flip mid-session without swapping objects.
StreamMessageV2Event.build(channel.groupId.id, tagger.message) {
replyTo.value?.let { parent ->
// The parent's root marker (when it is itself a nested reply),
// else the parent's OWN reply target (a direct reply's collapsed
// form carries the root as its "reply" marker — Buzz's relay
// validates ancestry and rejects a mis-derived root), else the
// parent starts the thread.
val parentTags = parent.event?.tags
val root =
parentTags?.buzzThreadRoot()
?: parentTags?.buzzThreadReply()
?: parent.idHex
buzzThread(root, parent.idHex)
parent.author?.pubkeyHex?.let { pTag(PTag(it)) }
}
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
channel is RelayGroupChannel -> {
// NIP-29 group message: a kind-9 chat scoped to the group with an
// `h` tag. The event is published only to the group's host relay
@@ -729,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>
@@ -3288,6 +3290,22 @@
<string name="chat_system_created_channel">%1$s created the channel %2$s</string>
<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

@@ -0,0 +1,193 @@
/*
* 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.model
import android.os.Looper
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.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.UUID
/**
* The Buzz dialect of NIP-29 in `LocalCache`: dialect detection off VERIFIED events,
* timeline attachment into the group's (stable, never-swapped) `RelayGroupChannel`, and
* the kind-40003 edit overlay held in `BuzzWorkspaceStates` keyed by the channel id.
*/
class BuzzWorkspaceChannelTest {
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private val signer = NostrSignerInternal(KeyPair())
@Before
fun setup() {
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
// where null == null reads as "main". Distinct mocks make it a worker thread.
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
BuzzRelayDialect.clearForTesting()
BuzzWorkspaceStates.clearForTesting()
}
@After
fun tearDown() {
unmockkStatic(Looper::class)
BuzzRelayDialect.clearForTesting()
BuzzWorkspaceStates.clearForTesting()
}
private fun newChannelId() = UUID.randomUUID().toString()
private suspend fun streamMessage(
channelId: String,
text: String,
) = signer.sign(StreamMessageV2Event.build(channelId, text))
@Test
fun verifiedBuzzKindMarksDialectAndAttachesToTheSameChannel() =
runBlocking {
val channelId = newChannelId()
val msg = streamMessage(channelId, "hello workspace")
assertFalse(BuzzRelayDialect.isBuzz(buzzRelay))
LocalCache.checkDeletionAndConsume(msg, buzzRelay, false)
assertTrue("a verified 40002 marks the serving relay as Buzz", BuzzRelayDialect.isBuzz(buzzRelay))
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))
assertNotNull("the group channel exists after a 40002 lands", channel)
// The channel type never changes on dialect discovery — screens capture it for life.
assertTrue(channel is RelayGroupChannel)
assertTrue("the message is on the channel timeline", channel!!.notes.containsKey(msg.id))
}
@Test
fun channelInstanceIsStableAcrossDialectDiscovery() =
runBlocking {
val channelId = newChannelId()
val key = GroupId(channelId, buzzRelay)
// The group materializes plain (e.g. via 39000) BEFORE any Buzz kind reveals the dialect.
val before = LocalCache.getOrCreateRelayGroupChannel(key)
LocalCache.checkDeletionAndConsume(streamMessage(channelId, "first buzz kind"), buzzRelay, false)
val after = LocalCache.getRelayGroupChannelIfExists(key)
// Same object: no swap, so every live screen/feed/composer reference stays valid.
assertTrue("dialect discovery must NOT swap the channel instance", before === after)
}
@Test
fun unverifiedEventDoesNotMarkTheDialect() =
runBlocking {
val channelId = newChannelId()
// Tamper the signature so verification fails.
val real = streamMessage(channelId, "spoof")
val forged = StreamMessageV2Event(real.id, real.pubKey, real.createdAt, real.tags, real.content, "00".repeat(64))
LocalCache.checkDeletionAndConsume(forged, buzzRelay, false)
assertFalse("an unverifiable event must not flip the dialect", BuzzRelayDialect.isBuzz(buzzRelay))
}
@Test
fun editOverlayTracksNewestEditAndIsNotATimelineRow() =
runBlocking {
val channelId = newChannelId()
val original = streamMessage(channelId, "teh typo")
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
val edit1 =
signer.sign(
StreamMessageEditEvent.build(channelId, original.id, "the typo", createdAt = original.createdAt + 10),
)
val edit2 =
signer.sign(
StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 20),
)
// Out-of-order arrival: newer first, older second.
LocalCache.checkDeletionAndConsume(edit2, buzzRelay, false)
LocalCache.checkDeletionAndConsume(edit1, buzzRelay, false)
val state = BuzzWorkspaceStates.getIfExists(channelId)!!
assertEquals("newest edit wins regardless of arrival order", "the fix", state.effectiveContentFor(original.id))
assertEquals(edit2.id, state.editFor(original.id)?.idHex)
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))!!
assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit1.id))
assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit2.id))
}
@Test
fun overlayIsKeyedByChannelIdSoOwnSendsWithNoRelayLand() =
runBlocking {
// An edit consumed with a null provenance relay (own optimistic send) must
// still record its overlay — the registry is keyed by channel id, not relay.
val channelId = newChannelId()
val original = streamMessage(channelId, "original")
LocalCache.checkDeletionAndConsume(original, null, true)
val edit =
signer.sign(
StreamMessageEditEvent.build(channelId, original.id, "edited offline", createdAt = original.createdAt + 5),
)
LocalCache.checkDeletionAndConsume(edit, null, true)
assertEquals("edited offline", BuzzWorkspaceStates.getIfExists(channelId)?.effectiveContentFor(original.id))
}
@Test
fun pruneDropsOverlaysForMessagesNoLongerInTheChannel() =
runBlocking {
val channelId = newChannelId()
val original = streamMessage(channelId, "will be pruned")
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "edit", createdAt = original.createdAt + 5))
LocalCache.checkDeletionAndConsume(edit, buzzRelay, false)
val state = BuzzWorkspaceStates.getIfExists(channelId)!!
assertNotNull(state.editFor(original.id))
// Simulate the message having been reaped from the channel.
state.pruneEdits(emptySet())
assertNull("overlay for a pruned message must be dropped", state.editFor(original.id))
}
}

View File

@@ -0,0 +1,59 @@
/*
* 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.datasource
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Every relay-group timeline REQ carries the Buzz stream kinds unconditionally (they
* simply match nothing on a vanilla relay); gating them created a history-cursor bug.
*/
class BuzzTimelineKindsTest {
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private val vanillaRelay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example.com/")!!
@Test
fun theUnifiedTimelineKindSetContainsBothNip29AndBuzz() {
assertTrue(RELAY_GROUP_ALL_TIMELINE_KINDS.containsAll(RELAY_GROUP_TIMELINE_KINDS))
assertTrue(RELAY_GROUP_ALL_TIMELINE_KINDS.containsAll(BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS))
}
@Test
fun everyTimelineReqCarriesBuzzKindsUnconditionally() {
// The Buzz kinds are requested on EVERY relay-group timeline REQ (open tail,
// joined fleet tail), independent of any dialect mark. Gating them created a
// history-cursor-skip bug — this pins the unconditional contract.
val openTail = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L)
assertTrue(openTail.filter.kinds!!.contains(StreamMessageV2Event.KIND))
val fleet =
buildRelayGroupJoinedChatTailFilters(
listOf(GroupTag("g1", buzzRelay.url), GroupTag("g2", vanillaRelay.url)),
sinceEpoch = 0L,
)
fleet.forEach { assertTrue(it.filter.kinds!!.contains(StreamMessageV2Event.KIND)) }
}
}

View File

@@ -113,7 +113,9 @@ class RelayGroupFilterBuildersTest {
assertEquals(2, filters.size)
val a = filters.single { it.relay == relayA }
assertEquals(timelineKinds, a.filter.kinds)
// The joined tail now always carries the Buzz timeline kinds too (see
// RELAY_GROUP_ALL_TIMELINE_KINDS / BuzzTimelineKindsTest).
assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, a.filter.kinds)
assertEquals(setOf("g1", "g2"), a.filter.tags!!["h"]!!.toSet())
assertEquals(999L, a.filter.since)
assertNull("a time-floored tail must NOT cap by count (that is what lets it batch)", a.filter.limit)
@@ -126,7 +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)
assertEquals(timelineKinds, 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,69 @@
/*
* 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.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Tracks which relays speak the **Buzz dialect** of NIP-29 — `block/buzz` workspace
* relays whose channel timelines carry Buzz-custom kinds (stream messages 40002,
* edits 40003, diffs 40008, system rows 40099, canvas 40100, forum 45001/45003,
* agent jobs 43001-43006, huddle cards 48100-48103) alongside plain kind-9 chat.
*
* Detection sources, in order of arrival:
* 1. **Event shape** (implemented): the first Buzz-only channel kind consumed from a
* relay marks it (`LocalCache` calls [mark] before materializing the channel). A
* vanilla NIP-29 relay never serves those kinds, so there are no false positives.
* 2. **NIP-11** (future): a relay announcing Buzz in its info document can be marked
* at connect time, before any event arrives.
*
* The registry decides which channel type `LocalCache` materializes for a group
* (`BuzzWorkspaceChannel` vs plain `RelayGroupChannel`) and which timeline kinds the
* group-chat REQ builders put on the wire for that relay. Marks only ever ADD
* capability: un-marking is never needed because a marked relay provably served a
* Buzz kind, and extra requested kinds are harmless on any relay.
*
* Like `LocalCache`, this is a process-wide singleton (one copy per Android process).
*/
object BuzzRelayDialect {
private val buzzRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** Relays known to speak the Buzz dialect; UI can collect this for affordances. */
val flow: StateFlow<Set<NormalizedRelayUrl>> = buzzRelays
fun isBuzz(relay: NormalizedRelayUrl): Boolean = relay in buzzRelays.value
/** Marks [relay] as Buzz. Returns true when this call changed the state. */
fun mark(relay: NormalizedRelayUrl): Boolean {
while (true) {
val current = buzzRelays.value
if (relay in current) return false
if (buzzRelays.compareAndSet(current, current + relay)) return true
}
}
/** Test-only: clears all marks so unit tests don't leak state into each other. */
fun clearForTesting() {
buzzRelays.value = emptySet()
}
}

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

@@ -0,0 +1,120 @@
/*
* 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.model.Note
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Buzz-only overlay state for one workspace channel: the kind-40003 edit overlay
* (newest edit per message — rendering the original without it shows stale text as
* current) and the newest kind-40100 canvas.
*
* This lives OUTSIDE the channel object on purpose. Screens, feed filters, and
* composers capture their `RelayGroupChannel` instance once and hold it for the whole
* session, so a channel can never be swapped for a "Buzz-typed" replacement when the
* dialect is discovered mid-session — every live reference would keep rendering the
* orphaned instance. Keeping the overlay in a registry keyed by the channel id makes
* dialect discovery a non-event for object identity.
*
* All mutations are guarded by a per-state lock: consume runs on multiple relay
* dispatcher threads, and unsynchronized check-then-act would let an older edit
* overwrite a newer one.
*/
class BuzzWorkspaceState {
private val lock = KmpLock()
private val editsByTarget = LargeCache<HexKey, Note>()
private val editVersion = MutableStateFlow(0)
/** Bumps when any overlay entry changes, so rows re-read [editFor]. */
val editUpdates: StateFlow<Int> = editVersion
/** The newest canvas (kind 40100) note for this channel, or null when none seen. */
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,
editNote: Note,
) = lock.withLock {
val current = editsByTarget.get(targetId)
if (current == null || (editNote.createdAt() ?: 0L) > (current.createdAt() ?: 0L)) {
editsByTarget.put(targetId, editNote)
editVersion.value = editVersion.value + 1
}
}
/** The newest edit note overlaying [targetId], or null when the message is unedited. */
fun editFor(targetId: HexKey): Note? = editsByTarget.get(targetId)
/** The effective display content for a message: its newest edit's text, or null when unedited. */
fun effectiveContentFor(targetId: HexKey): String? = editsByTarget.get(targetId)?.event?.content
fun updateCanvas(note: Note) =
lock.withLock {
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
canvasNote = note
canvasVersion.value = canvasVersion.value + 1
}
}
/** Drops overlay entries whose target message id is not in [aliveTargetIds] (memory pruning). */
fun pruneEdits(aliveTargetIds: Set<HexKey>) =
lock.withLock {
val dead = editsByTarget.keys().filter { it !in aliveTargetIds }
if (dead.isNotEmpty()) {
dead.forEach { editsByTarget.remove(it) }
editVersion.value = editVersion.value + 1
}
}
}
/**
* Registry of [BuzzWorkspaceState] keyed by the channel's `h` id. Buzz channel ids are
* relay-generated UUIDs (NIP-11 `h_grammar: uuid-v4-lowercase`), so the id alone is a
* sound key — no relay scoping needed, which also lets own-authored edits (consumed
* with no provenance relay) land in the right state.
*
* Like `LocalCache`, a process-wide singleton.
*/
object BuzzWorkspaceStates {
private val states = LargeCache<String, BuzzWorkspaceState>()
fun getOrCreate(channelId: String): BuzzWorkspaceState = states.getOrCreate(channelId) { BuzzWorkspaceState() }
fun getIfExists(channelId: String): BuzzWorkspaceState? = states.get(channelId)
/** Test-only: clears all state so unit tests don't leak into each other. */
fun clearForTesting() {
states.clear()
}
}

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

@@ -0,0 +1,271 @@
# Buzz protocol (`com.vitorpamplona.quartz.buzz`)
Quartz-native models for the [`block/buzz`](https://github.com/block/buzz) protocol —
a self-hosted "workspace where humans and agents build together, on a relay you own."
Buzz runs **NIP-01 on the wire** and uses the `kind` integer as its only dispatch
switch. Architecturally it is a member of the **NIP-29 relay-group family**, *not* an
encrypted client-side community like `concord`: the relay is the single source of
truth, it enforces membership server-side, it keeps content **plaintext** (so it can
run full-text search, audit, and workflows), and it signs overlay/state events. Buzz
literally reuses NIP-29 primitives — the `h` channel tag, kind `9` stream messages,
the `9000`/`9001`/`9002` admin events, and the `39000`/`39002` relay-signed metadata —
and layers an agent + workspace vocabulary on top.
Because of that lineage, this package models only the **Buzz-custom extensions**. The
standard/NIP-29 kinds are reused from their existing Quartz packages
(`nip29RelayGroups`, `nip34Git`, `nip51Lists`, `nip42RelayAuth`, `nip43RelayMembers`,
…). There is no central kind registry — each event declares its own `const val KIND`
on its companion (the idiomatic Quartz convention), and dispatch is wired in
`utils/EventFactory.kt` like every other kind.
## Source of truth
The prose specs under Buzz's `docs/nips/` are **drafts and lag the code**. Every model
here is confirmed against the authoritative Rust — `crates/buzz-core` (the per-kind
modules), `crates/buzz-sdk` (event builders, `nip_oa.rs`) — not the markdown.
Compliance is verified against **vectors generated by Buzz's own code**, not
hand-transcribed schemas:
- **NIP-OA** pins Buzz's published known-answer vector (`OwnerAttestationTest`): our
preimage hash equals Buzz's SHA-256 and our verifier accepts Buzz's reference
signature.
- **NIP-AM** pins a real signed event emitted by a tiny generator built on the real
`buzz-core` (deterministic keys owner sk=1 / agent sk=2). `AgentTurnMetricVectorTest`
(jvmTest) dispatches it through `EventFactory`, NIP-44-decrypts it, and asserts the
payload — proving Quartz reads real Buzz events end to end. The fixture and its
generator live at `quartz/src/jvmTest/resources/buzz/turn_metric_vector.json` and
`.../gen_turn_metric.rs` (drop the `.rs` into a `block/buzz` checkout's
`crates/buzz-core/examples/` and run `cargo run -p buzz-core --example gen_turn_metric`
to regenerate).
## Layout
Each feature is a sub-package following the standard Quartz per-NIP shape (`<Feature>Event`
with a `KIND` companion, a `tags/` folder of tag classes, `TagArrayBuilderExt` write-DSL
verbs, and `TagArrayExt` read accessors), mirroring e.g. `nip88Polls`. There is one event
class per kind, ~78 in total, each registered in `utils/EventFactory.kt` (except the
conflicts below).
| Package | Buzz NIP | Kind(s) |
|---|---|---|
| `oaOwnerAttestation` | NIP-OA | `auth` tag (no kind) |
| `amTurnMetrics` | NIP-AM | 44200 |
| `aoObserver` | NIP-AO | 24200 |
| `aeEngrams` | NIP-AE | 30174 |
| `apPersonas` / `teams` / `managedAgents` / `agentProfiles` | NIP-AP + agent identity | 30175 / 30176 / 30177 / 10100 |
| `erReminders` / `plPushLease` / `dvDmVisibility` / `wpWorkspaceProfile` | NIP-ER/PL/DV/WP | 30300 / 30350 / 30622 / 9033 |
| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006 |
| `relayAdmin` / `moderation` | admin + moderation | 9030-9032 / 9040-9044, 42000 |
| `stream` / `stream.sidecars` | stream messaging | 40002-40008, 40099, 40100 / 40901, 40902 |
| `dm` / `jobs` | DMs / agent jobs | 41001, 41010-41012 / 43001-43006 |
| `workflow` / `forum` / `notifications` | workflow + social | 30620, 46001-46031 / 45001-45003 / 44100, 44101 |
| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 / 24134 / 48001 / 49001 |
| `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) |
### Kind conflicts — implemented but NOT registered in EventFactory
These Buzz kind numbers are already owned by an existing Amethyst/Nostr class, so the
Buzz model exists (build/parse it explicitly) but the incumbent keeps the `EventFactory`
dispatch slot:
| Kind | Buzz class | Incumbent (registered) |
|---|---|---|
| 9041 | `moderation.ModerationUnbanEvent` | `nip75ZapGoals.GoalEvent` |
| 20001 | `presence.PresenceUpdateEvent` | `experimental.bitchat.geohash.GeohashPresenceEvent` |
| 39005 | `cwChannelWindow.ThreadSummaryEvent` | `nip29RelayGroups.metadata.GroupPinnedEvent` |
| 49001 | `media.MediaUploadEvent` | — (Buzz's own `kind.rs` marks 49001 "Not a relay event kind") |
| 30078 | `rsReadState` (helpers) | `nip78AppData.AppSpecificDataEvent` (NIP-RS reuses 30078) |
### Confidence: source-confirmed vs. inferred
Most kinds' tags/content are confirmed against a concrete Rust builder or validator. A
minority are only *reserved* in `kind.rs` with no constructor anywhere in the Buzz repo;
those are modeled conservatively (tolerant, `ignoreUnknownKeys`) and carry a schema
caveat in their KDoc. Inferred kinds include: jobs **43001-43006** (reserved, no builder,
no relay scope — the most speculative), the workflow lifecycle events **46001-46007 /
46011 / 46012**, stream **40004-40007** and the **40901/40902** sidecars, **48001** audit,
and **48106** huddle guidelines. Treat these as scaffolding to reconcile once Buzz
defines them.
## Live interoperability — verified against a running Buzz relay
`buzz/interop/BuzzRelayLiveInteropTest.kt` (jvmTest, env-gated so CI skips it) drives a
**real `buzz-relay`** (Postgres + Redis + MinIO via Buzz's compose, relay via
`cargo run -p buzz-relay`, members enrolled with `buzz-admin add-member`):
```
BUZZ_RELAY_WS=ws://localhost:3000 \
BUZZ_MEMBER_SK=<member sk hex> BUZZ_OWNER_SK=<member sk hex> \
./gradlew :quartz:jvmTest --tests "*BuzzRelayLiveInteropTest"
```
Verified end-to-end (relay log shows the ingests):
- **NIP-42 auth** with a member key, and — critically — **NIP-OA/NIP-AA agent auth**: a
brand-new, un-enrolled agent key authenticated using only our owner-signed `auth`
tag on the AUTH event (the relay's NIP-OA membership fallback accepted it).
- **Channel lifecycle**: kind:9007 create (via the existing NIP-29 `CreateGroupEvent`
Buzz channels ARE NIP-29 groups) then kind:40002 publish, `REQ` round-trip with a
byte-identical echo (same event id), and `EOSE`.
Interop lessons encoded in the test:
- Buzz tenancy is **host-bound**: connect with the exact host the community was bound
to (`ws://localhost:3000`, not `ws://127.0.0.1:3000`) or the upgrade 404s.
- `h` tag values must parse as **UUIDs** (`extract_channel_id`); a non-UUID `h` is
treated as absent and channel-scoped kinds are rejected.
- The channel must exist (9007) before channel-scoped kinds are accepted.
## Client integration (commons + amethyst)
Buzz workspaces surface in Amethyst as **NIP-29 groups in the Buzz dialect** — one
group model, two dialect renderers, and no impact on vanilla groups (feeds are
kind-filtered, so they can never receive Buzz kinds by accident):
- `commons/.../model/buzz/BuzzRelayDialect.kt` — per-relay capability registry.
Detection is event-shape based: the first Buzz-only kind consumed from a relay marks
it (no false positives — vanilla relays never serve those kinds). NIP-11 marking can
be added later for pre-connect detection.
- `commons/.../model/buzz/BuzzWorkspaceStates.kt` — Buzz-only overlay state (the
kind-40003 **edit overlay** — never render superseded text as current — and the
newest kind-40100 **canvas**), held in a registry keyed by the channel's UUID,
**outside** the channel object. This is deliberate: screens/feeds/composers capture
their `RelayGroupChannel` instance for the whole session, so the dialect must be able
to flip mid-session without swapping the object (an earlier subclass-swap design froze
every live reference). Kind-9 chat and kind-40002 stream messages share ONE
`RelayGroupChannel` timeline per group, so mixed-dialect conversations stay whole.
- `amethyst LocalCache` consumes every registered Buzz kind (they previously fell into
the "Event Not Supported" branch): timeline kinds attach to the group's
`BuzzWorkspaceChannel` (materialized dialect-aware, with an in-place upgrade when the
dialect is discovered after the channel was first created), addressables store
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 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.
### 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
The primitive that makes "agents as first-class members" work without enrolling every
agent key. An owner signs a standalone commitment authorizing an agent pubkey to
publish under conditions; the relay grants virtual membership while the owner is a
member. See `oaOwnerAttestation/OwnerAttestation.kt`.
```
commitment = "nostr:agent-auth:" + agentPubKey + ":" + conditions
message = SHA-256(commitment)
sig = BIP-340 Schnorr(message, ownerPrivKey) // over the 32-byte digest
tag = ["auth", ownerPubKey, conditions, sig]
```
The signed message is not an event, so only a signer holding the raw owner private key
can produce it (a NIP-46 bunker / NIP-55 external signer cannot) — hence `sign()` takes
a `KeyPair`/private key, not a `NostrSigner`.
## Agent Turn Metric (NIP-AM, kind:44200) — implemented
An encrypted, durable record of one agent turn's token usage and cost, published by the
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.

View File

@@ -0,0 +1,113 @@
/*
* 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.quartz.buzz.aeEngrams
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* The decrypted body of an Agent Engram (NIP-AE, `kind:30174`). The `slug`
* discriminates the variant: `core` is the agent's identity surface, everything
* else (`mem/…`) is one memory entry. Ground truth:
* `buzz-core/src/engram.rs` (`Body`).
*
* Bodies are serialized whitespace-free with `slug` first, and — unlike the rest
* of the Buzz payloads — `null` is written explicitly (a memory with `value:null`
* is a **tombstone**, distinct from an absent field), so [JSON] keeps
* `explicitNulls = true` to match Buzz's byte-exact encoding.
*/
sealed interface EngramBody {
/** The slug this body addresses. */
val slug: String
fun encodeToJson(): String
companion object {
const val CORE_SLUG = "core"
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = true
encodeDefaults = true
}
/**
* Parses a decrypted engram body, dispatching on `slug`: a `core` slug
* yields an [EngramCoreBody], anything else an [EngramMemoryBody]. Throws
* if `slug` is missing or not a string.
*/
fun decodeFromJson(json: String): EngramBody {
val slug =
JSON
.parseToJsonElement(json)
.jsonObject["slug"]
?.jsonPrimitive
?.content
?: throw IllegalArgumentException("Engram body is missing a string `slug`")
return if (slug == CORE_SLUG) {
JSON.decodeFromString<EngramCoreBody>(json)
} else {
require(isValidMemorySlug(slug)) { "Invalid engram memory slug: $slug" }
JSON.decodeFromString<EngramMemoryBody>(json)
}
}
private val MEMORY_SLUG = Regex("^mem/[a-z0-9][a-z0-9_-]{0,63}(/[a-z0-9][a-z0-9_-]{0,63})*$")
/** NIP-AE memory-slug grammar (`validate_slug` in engram.rs); `core` is handled separately. */
fun isValidMemorySlug(slug: String): Boolean = slug.length <= 255 && MEMORY_SLUG.matches(slug)
}
}
/**
* A `mem/…` engram entry. [value] `null` is a tombstone (the entry was deleted),
* encoded as an explicit `"value":null` on the wire.
*
* [value] has NO default on purpose: a memory body that OMITS `value` entirely is
* invalid (Buzz's `Body::from_json_bytes` rejects it), not a tombstone. With no
* default, kotlinx throws on an absent field, so Amethyst refuses the same bodies
* Buzz does instead of silently reading them as a deletion.
*/
@Serializable
data class EngramMemoryBody(
override val slug: String,
val value: String?,
) : EngramBody {
/** `true` for a tombstone — a memory whose [value] is `null`. */
fun isTombstone(): Boolean = value == null
override fun encodeToJson(): String = EngramBody.JSON.encodeToString(this)
}
/** The reserved `core` engram — the agent's free-form identity/profile surface. */
@Serializable
data class EngramCoreBody(
override val slug: String = EngramBody.CORE_SLUG,
val profile: String,
) : EngramBody {
override fun encodeToJson(): String = EngramBody.JSON.encodeToString(this)
}

View File

@@ -0,0 +1,67 @@
/*
* 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.quartz.buzz.aeEngrams
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.mac.MacInstance
/**
* Derivation of the blinded `d` tag for an Agent Engram (NIP-AE, `kind:30174`).
*
* The address is `(pubkey_a, kind, d)` where
* `d = lower_hex(HMAC-SHA256(K_c, "agent-memory/v1/d-tag" || 0x00 || slug))`,
* 64 hex characters. `K_c` is the NIP-44 v2 conversation key shared by the agent
* and its owner — blinding the slug so a relay can address the record without
* learning it. Ground truth: `buzz-core/src/engram.rs` (`d_tag`, `D_TAG_DOMAIN`).
*
* ## Limitation
* Quartz's [com.vitorpamplona.quartz.nip01Core.signers.NostrSigner] does **not**
* expose the NIP-44 conversation key (it is held inside the signer, and remote
* NIP-46 / external NIP-55 signers never reveal it), so the d tag cannot be
* derived from a signer alone. Callers that hold the raw conversation key bytes
* (e.g. a local keypair path that computes `K_c` out of band) can use [derive];
* otherwise the d tag must be supplied to
* [EngramEvent.create] as an opaque 64-hex string obtained from wherever `K_c`
* is available.
*/
object EngramDTag {
/**
* Domain-separation prefix for the `d`-tag HMAC, followed by a `0x00` byte and
* the slug. Versioned independently of the NIP number.
*/
const val DOMAIN = "agent-memory/v1/d-tag"
/**
* Computes the blinded `d` tag for [slug] under the raw NIP-44 v2 conversation
* key [conversationKey] (32 bytes). Returns 64 lowercase hex characters.
*/
fun derive(
conversationKey: ByteArray,
slug: String,
): HexKey {
val mac = MacInstance("HmacSHA256", conversationKey)
mac.update(DOMAIN.encodeToByteArray())
mac.update(0x00)
mac.update(slug.encodeToByteArray())
return mac.doFinal().toHexKey()
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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.quartz.buzz.aeEngrams
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* A Buzz Agent Engram (NIP-AE, `kind:30174`): an addressable, encrypted memory
* record for an AI agent, authored by the agent and readable by its owner.
*
* Addressed by `(pubkey_a, kind, d)`, where the `d` tag is a **blinded** slug —
* `HMAC-SHA256(K_c, "agent-memory/v1/d-tag" || 0x00 || slug)` over the agent
* owner NIP-44 conversation key (see [EngramDTag]). The `content` is a NIP-44 v2
* ciphertext of an [EngramBody] (a `mem/…` memory or the `core` identity).
* Because the conversation key is symmetric, either the agent (author) or the
* owner (`p` tag) can [decrypt]. Ground truth: `buzz-core/src/engram.rs`.
*
* ## d-tag limitation
* Quartz's [NostrSigner] does not expose the NIP-44 conversation key, so the `d`
* tag cannot be derived from the signer during [create]. The caller MUST supply
* it as an opaque 64-hex string — compute it with [EngramDTag.derive] wherever
* the raw conversation-key bytes are available.
*/
@Immutable
class EngramEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
/** The owner (recipient) pubkey — the single `p` tag. */
fun ownerPubKey() = tags.engramOwner()
/**
* Decrypts and parses the engram body. [signer] must belong to the agent
* (this event's author) or the owner (`p` tag). Throws on a missing
* counterparty, decryption failure, or malformed body; use [decryptOrNull]
* to swallow those.
*/
suspend fun decrypt(signer: NostrSigner): EngramBody {
val counterparty = if (signer.pubKey == pubKey) ownerPubKey() else pubKey
requireNotNull(counterparty) { "Engram is missing the owner (p) tag" }
val json = signer.decrypt(content, counterparty)
return EngramBody.decodeFromJson(json)
}
suspend fun decryptOrNull(signer: NostrSigner): EngramBody? =
try {
decrypt(signer)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
const val KIND = 30174
/**
* Builds and signs an engram: [signer] is the agent; the [body] is
* NIP-44-encrypted to [ownerPubKey]. [dTag] is the blinded slug address —
* an opaque 64-hex string the caller derives via [EngramDTag.derive] (the
* signer cannot supply the conversation key needed to compute it).
*/
suspend fun create(
body: EngramBody,
ownerPubKey: HexKey,
dTag: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): EngramEvent {
val ciphertext = signer.nip44Encrypt(body.encodeToJson(), ownerPubKey)
return signer.sign(build(ciphertext, ownerPubKey, dTag, createdAt))
}
fun build(
ciphertext: String,
ownerPubKey: HexKey,
dTag: HexKey,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EngramEvent>.() -> Unit = {},
) = eventTemplate<EngramEvent>(KIND, ciphertext, createdAt) {
dTag(dTag)
owner(ownerPubKey)
initializer()
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* 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.quartz.buzz.aeEngrams
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArrayBuilder<EngramEvent>.owner(ownerPubKey: HexKey) = addUnique(PTag.assemble(ownerPubKey, null))

View File

@@ -0,0 +1,27 @@
/*
* 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.quartz.buzz.aeEngrams
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
/** The owner (recipient) pubkey — the single `p` tag. */
fun TagArray.engramOwner() = firstNotNullOfOrNull(PTag::parseKey)

View File

@@ -0,0 +1,72 @@
/*
* 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.quartz.buzz.agentProfiles
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* The JSON body stored in a Buzz Agent Profile event ([AgentProfileEvent], `kind:10100`).
*
* MODELLED CONSERVATIVELY — FLAGGED. Buzz has no single authoritative struct for `kind:10100`
* content; the fields below are the union of the two consumers found in the Rust/TS ground truth:
*
* 1. The relay side effect `handle_agent_profile` in
* `buzz-relay/src/handlers/side_effects.rs` reads exactly one field, [channelAddPolicy]
* (`channel_add_policy`), to set the author's channel-add policy. The CLI writer
* (`buzz-cli/.../channels.rs`) publishes `{"channel_add_policy": "<policy>"}` and nothing
* else. Observed policy values: `anyone`, `owner_only`, `nobody`.
* 2. The desktop agent-discovery reader `agents_from_events` in
* `desktop/src-tauri/src/nostr_convert.rs` treats the content as loose kind-0-like agent
* metadata, reading [name], [displayName], [agentType], [status], [capabilities],
* [channelIds] and defaulting each when absent.
*
* No writer emits all of these together, so every field is optional and unknown keys are
* tolerated. The event author is the authoritative pubkey; any `pubkey` claimed inside the
* content is deliberately NOT modelled here (upstream overwrites it with the signer). If a
* definitive schema is published later, tighten this struct.
*/
@Serializable
data class AgentProfileContent(
@SerialName("channel_add_policy") val channelAddPolicy: String? = null,
val name: String? = null,
@SerialName("display_name") val displayName: String? = null,
@SerialName("agent_type") val agentType: String? = null,
val status: String? = null,
val capabilities: List<String> = emptyList(),
@SerialName("channel_ids") val channelIds: List<String> = emptyList(),
) {
fun encodeToJson(): String = JSON.encodeToString(this)
companion object {
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun decodeFromJson(json: String): AgentProfileContent = JSON.decodeFromString(json)
}
}

View File

@@ -0,0 +1,73 @@
/*
* 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.quartz.buzz.agentProfiles
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* A Buzz Agent Profile (`kind:10100`): a replaceable, world-readable, agent-authored event
* carrying agent metadata + the author's channel-add policy. Replaceable (10000-19999), so it
* is keyed by `(pubkey, 10100)` with an empty `d` tag and there is at most one per author.
*
* The `content` is a loose JSON [AgentProfileContent] — see that class for the FLAG: Buzz has no
* single authoritative schema, so the model is conservative and tolerant of unknown keys.
* Ground truth consumers: `buzz-relay/src/handlers/side_effects.rs::handle_agent_profile`
* (reads `channel_add_policy`) and `desktop/src-tauri/src/nostr_convert.rs::agents_from_events`
* (reads loose agent-discovery metadata).
*/
@Immutable
class AgentProfileEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** Parses the profile metadata, or throws if the JSON is malformed. */
fun profile(): AgentProfileContent = AgentProfileContent.decodeFromJson(content)
fun profileOrNull(): AgentProfileContent? =
try {
profile()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
const val KIND = 10100
fun build(
profile: AgentProfileContent,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AgentProfileEvent>.() -> Unit = {},
) = eventTemplate<AgentProfileEvent>(KIND, profile.encodeToJson(), createdAt) {
initializer()
}
}
}

View File

@@ -0,0 +1,109 @@
/*
* 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.quartz.buzz.amTurnMetrics
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* A Buzz Agent Turn Metric (NIP-AM, `kind:44200`): a durable, encrypted record of one
* AI-agent turn's token usage and cost, published by the agent to its owner.
*
* The `content` is a NIP-44 v2 ciphertext of an [AgentTurnMetricPayload], encrypted
* between the agent (the event author, also named by the `agent` tag) and the owner
* (the single `p` tag). Because the NIP-44 conversation key is symmetric, either party
* can decrypt with [decrypt]. Ground truth: `buzz-core/src/agent_turn_metric.rs`.
*/
@Immutable
class AgentTurnMetricEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
/** The owner (recipient) pubkey — the `p` tag. */
fun ownerPubKey() = tags.turnMetricOwner()
/** The publishing agent's pubkey — the `agent` tag (normally equal to [pubKey]). */
fun agentPubKey() = tags.turnMetricAgent()
/**
* Decrypts and parses the metric payload. [signer] must belong to either the agent
* (this event's author) or the owner. Throws on a missing counterparty, decryption
* failure, or malformed payload; use [decryptOrNull] to swallow those.
*/
suspend fun decrypt(signer: NostrSigner): AgentTurnMetricPayload {
val counterparty = if (signer.pubKey == pubKey) ownerPubKey() else pubKey
requireNotNull(counterparty) { "Turn metric is missing the owner (p) tag" }
val json = signer.decrypt(content, counterparty)
return AgentTurnMetricPayload.decodeFromJson(json)
}
suspend fun decryptOrNull(signer: NostrSigner): AgentTurnMetricPayload? =
try {
decrypt(signer)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
const val KIND = 44200
/**
* Builds and signs a turn-metric event: [signer] is the agent; the payload is
* NIP-44-encrypted to [ownerPubKey]. Fails if the payload violates NIP-AM
* numeric validity (a negative or non-finite `costUsd`).
*/
suspend fun create(
payload: AgentTurnMetricPayload,
ownerPubKey: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): AgentTurnMetricEvent {
require(payload.isValid()) { "costUsd must be finite and non-negative" }
val ciphertext = signer.nip44Encrypt(payload.encodeToJson(), ownerPubKey)
return signer.sign(build(ciphertext, ownerPubKey, signer.pubKey, createdAt))
}
fun build(
ciphertext: String,
ownerPubKey: HexKey,
agentPubKey: HexKey,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AgentTurnMetricEvent>.() -> Unit = {},
) = eventTemplate<AgentTurnMetricEvent>(KIND, ciphertext, createdAt) {
owner(ownerPubKey)
agent(agentPubKey)
initializer()
}
}
}

View File

@@ -0,0 +1,125 @@
/*
* 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.quartz.buzz.amTurnMetrics
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* The decrypted payload of a Buzz Agent Turn Metric (NIP-AM, `kind:44200`) — per-turn
* token usage and cost for one AI-agent turn.
*
* Field names, types, and nullability mirror `AgentTurnMetricPayload` in Buzz's
* `buzz-core/src/agent_turn_metric.rs` (camelCase on the wire). [harness] and
* [timestamp] are required; every other field is optional. Token fields are nullable
* on purpose — `null` means "the harness did not report this", not zero. Unknown JSON
* fields are ignored for forward compatibility, and an unrecognized [stopReason] is
* kept verbatim (map it with [stopReasonOrUnknown]) rather than dropping the payload.
*/
@Serializable
data class AgentTurnMetricPayload(
val harness: String,
val timestamp: String,
val model: String? = null,
val channelId: String? = null,
val sessionId: String? = null,
val turnId: String? = null,
val turnSeq: Long? = null,
val turn: TokenCounts? = null,
val cumulative: TokenCounts? = null,
val deltaReliable: Boolean = true,
val stopReason: String? = null,
) {
/** Maps [stopReason] to a [StopReason], treating any unrecognized value as [StopReason.UNKNOWN]. */
fun stopReasonOrUnknown(): StopReason? = stopReason?.let { StopReason.fromWire(it) }
/**
* NIP-AM numeric validity: any present `costUsd` must be finite and non-negative.
* (Token counts are non-negative by type.)
*/
fun isValid(): Boolean = turn.isCostValid() && cumulative.isCostValid()
fun encodeToJson(): String = JSON.encodeToString(this)
companion object {
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun decodeFromJson(json: String): AgentTurnMetricPayload = JSON.decodeFromString(json)
private fun TokenCounts?.isCostValid(): Boolean {
val cost = this?.costUsd ?: return true
return cost.isFinite() && cost >= 0.0
}
}
}
/**
* Token usage counts. All fields nullable — `null` distinguishes "not reported" from
* zero. Mirrors `TokenCounts` in `agent_turn_metric.rs`.
*/
@Serializable
data class TokenCounts(
val inputTokens: Long? = null,
val outputTokens: Long? = null,
val totalTokens: Long? = null,
val costUsd: Double? = null,
val cacheReadTokens: Long? = null,
val cacheWriteTokens: Long? = null,
)
/** Why an agent turn ended. Wire values are snake_case; unknown maps to [UNKNOWN]. */
@Serializable
enum class StopReason {
@SerialName("end_turn")
END_TURN,
@SerialName("max_tokens")
MAX_TOKENS,
@SerialName("cancelled")
CANCELLED,
@SerialName("error")
ERROR,
@SerialName("unknown")
UNKNOWN,
;
companion object {
fun fromWire(value: String): StopReason =
when (value) {
"end_turn" -> END_TURN
"max_tokens" -> MAX_TOKENS
"cancelled" -> CANCELLED
"error" -> ERROR
else -> UNKNOWN
}
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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.quartz.buzz.amTurnMetrics
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArrayBuilder<AgentTurnMetricEvent>.owner(ownerPubKey: HexKey) = addUnique(PTag.assemble(ownerPubKey, null))
fun TagArrayBuilder<AgentTurnMetricEvent>.agent(agentPubKey: HexKey) = addUnique(AgentTag.assemble(agentPubKey))

View File

@@ -0,0 +1,31 @@
/*
* 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.quartz.buzz.amTurnMetrics
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
/** The owner (recipient) pubkey — the single `p` tag. */
fun TagArray.turnMetricOwner() = firstNotNullOfOrNull(PTag::parseKey)
/** The publishing agent's pubkey — the `agent` tag. */
fun TagArray.turnMetricAgent() = firstNotNullOfOrNull(AgentTag::parse)

View File

@@ -0,0 +1,46 @@
/*
* 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.quartz.buzz.amTurnMetrics.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The Buzz `agent` tag — carries the publishing agent's public key. Used by the agent
* observability/accounting kinds (NIP-AM `kind:44200`, NIP-AO `kind:24200`) to name the
* agent independently of the event author, and normally equals the event `pubkey`.
*/
object AgentTag {
const val TAG_NAME = "agent"
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Tag): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(agentPubKey: HexKey) = arrayOf(TAG_NAME, agentPubKey)
}

View File

@@ -0,0 +1,163 @@
/*
* 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.quartz.buzz.aoObserver
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* A Buzz Agent Observer Frame (NIP-AO, `kind:24200`): a transient, owner-scoped,
* encrypted agent telemetry or control message. Ephemeral — relays route it on
* its cleartext tags and never store it.
*
* Tags are all cleartext so a relay can route without reading ACP internals:
* - `p` — the recipient ([recipientPubKey]); the owner for telemetry, the agent for control.
* - `agent` — the agent whose observer stream this frame belongs to ([agentPubKey]).
* - `frame` — the direction ([frame]): `telemetry` (agent-to-owner) or `control` (owner-to-agent).
*
* The `content` is a NIP-44 v2 ciphertext between the two parties. Because the
* conversation key is symmetric either party can [decryptTelemetry] /
* [decryptControl]. Ground truth: `buzz-core/src/observer.rs`,
* `buzz-sdk/src/builders.rs` (`build_agent_observer_frame`), and the relay
* routing in `buzz-relay/src/handlers/event.rs` (`agent_observer_route`).
*/
@Immutable
class ObserverFrameEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
/** The recipient pubkey — the `p` tag (owner for telemetry, agent for control). */
fun recipientPubKey() = tags.observerRecipient()
/** The agent this observer stream belongs to — the `agent` tag. */
fun agentPubKey() = tags.observerAgent()
/** The cleartext frame direction — the `frame` tag (`telemetry` or `control`). */
fun frame() = tags.observerFrame()
/**
* Resolves the NIP-44 counterparty for [signer]: if the signer authored the
* event, it is the `p` recipient; otherwise it is the author. Either party
* to the frame holds the same symmetric conversation key.
*/
private fun counterparty(signer: NostrSigner): HexKey? = if (signer.pubKey == pubKey) recipientPubKey() else pubKey
/**
* Decrypts and parses a `frame:telemetry` body. [signer] must belong to the
* agent (author) or the owner (`p` recipient). Throws on a missing
* counterparty, decryption failure, or malformed payload.
*/
suspend fun decryptTelemetry(signer: NostrSigner): ObserverTelemetryPayload {
val counterparty = counterparty(signer)
requireNotNull(counterparty) { "Observer frame is missing the recipient (p) tag" }
val json = signer.decrypt(content, counterparty)
return ObserverTelemetryPayload.decodeFromJson(json)
}
suspend fun decryptTelemetryOrNull(signer: NostrSigner): ObserverTelemetryPayload? =
try {
decryptTelemetry(signer)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
/**
* Decrypts and parses a `frame:control` body. [signer] must belong to the
* owner (author) or the agent (`p` recipient). Throws on a missing
* counterparty, decryption failure, or malformed payload.
*/
suspend fun decryptControl(signer: NostrSigner): ObserverControlPayload {
val counterparty = counterparty(signer)
requireNotNull(counterparty) { "Observer frame is missing the recipient (p) tag" }
val json = signer.decrypt(content, counterparty)
return ObserverControlPayload.decodeFromJson(json)
}
suspend fun decryptControlOrNull(signer: NostrSigner): ObserverControlPayload? =
try {
decryptControl(signer)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
const val KIND = 24200
/**
* Builds and signs an agent-to-owner telemetry frame: [signer] is the
* agent, the payload is NIP-44-encrypted to [ownerPubKey], the `p` tag is
* the owner, and the `frame` tag is [FrameTag.TELEMETRY].
*/
suspend fun createTelemetry(
payload: ObserverTelemetryPayload,
ownerPubKey: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): ObserverFrameEvent {
val ciphertext = signer.nip44Encrypt(payload.encodeToJson(), ownerPubKey)
return signer.sign(build(ciphertext, ownerPubKey, signer.pubKey, FrameTag.TELEMETRY, createdAt))
}
/**
* Builds and signs an owner-to-agent control frame: [signer] is the owner,
* the payload is NIP-44-encrypted to [agentPubKey], the `p` tag is the
* agent, and the `frame` tag is [FrameTag.CONTROL].
*/
suspend fun createControl(
payload: ObserverControlPayload,
agentPubKey: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): ObserverFrameEvent {
val ciphertext = signer.nip44Encrypt(payload.encodeToJson(), agentPubKey)
return signer.sign(build(ciphertext, agentPubKey, agentPubKey, FrameTag.CONTROL, createdAt))
}
fun build(
ciphertext: String,
recipientPubKey: HexKey,
agentPubKey: HexKey,
frame: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ObserverFrameEvent>.() -> Unit = {},
) = eventTemplate<ObserverFrameEvent>(KIND, ciphertext, createdAt) {
recipient(recipientPubKey)
agent(agentPubKey)
frame(frame)
initializer()
}
}
}

View File

@@ -0,0 +1,101 @@
/*
* 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.quartz.buzz.aoObserver
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
/**
* The decrypted plaintext of an agent observer frame (NIP-AO, `kind:24200`).
*
* Two shapes travel over the same NIP-44 v2 ciphertext; the cleartext `frame`
* tag ([com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag]) says which:
* - [ObserverTelemetryPayload] for `frame:telemetry` (agent-to-owner).
* - [ObserverControlPayload] for `frame:control` (owner-to-agent).
*/
object ObserverPayload {
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
}
/**
* Agent-to-owner telemetry frame body. Mirrors `ObserverEvent` in
* `buzz-acp/src/observer.rs` (serde `rename_all = "camelCase"`), one entry from
* the harness's in-process observer bus.
*
* [seq] is a process-local monotonic counter, [timestamp] is RFC3339 UTC, and
* [kind] is a semantic label such as `acp_read`, `turn_started`, or
* `control_result`. Every id/context field is optional. [payload] is the raw
* or semantic event body, kept as an opaque [JsonElement] because its schema
* varies per [kind].
*/
@Serializable
data class ObserverTelemetryPayload(
val seq: Long,
val timestamp: String,
val kind: String,
val agentIndex: Int? = null,
val channelId: String? = null,
val sessionId: String? = null,
val turnId: String? = null,
val startedAt: String? = null,
val payload: JsonElement? = null,
) {
fun encodeToJson(): String = ObserverPayload.JSON.encodeToString(this)
companion object {
fun decodeFromJson(json: String): ObserverTelemetryPayload = ObserverPayload.JSON.decodeFromString(json)
}
}
/**
* Owner-to-agent control frame body. The relay never reads this — it routes on
* the cleartext `frame:control` tag — so the schema is defined only by the
* agent-side reader in `buzz-acp/src/lib.rs` (`handle_relay_observer_control_event`),
* which dispatches on [type] and pulls [channelId] / [modelId] ad hoc.
*
* Known [type] values are [CANCEL_TURN] and [SWITCH_MODEL]; an unrecognized
* value is retained verbatim (the agent silently ignores it) rather than
* dropping the payload.
*/
@Serializable
data class ObserverControlPayload(
@SerialName("type") val type: String,
val channelId: String? = null,
val modelId: String? = null,
) {
fun encodeToJson(): String = ObserverPayload.JSON.encodeToString(this)
companion object {
const val CANCEL_TURN = "cancel_turn"
const val SWITCH_MODEL = "switch_model"
fun decodeFromJson(json: String): ObserverControlPayload = ObserverPayload.JSON.decodeFromString(json)
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.quartz.buzz.aoObserver
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArrayBuilder<ObserverFrameEvent>.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null))
fun TagArrayBuilder<ObserverFrameEvent>.agent(agentPubKey: HexKey) = addUnique(AgentTag.assemble(agentPubKey))
fun TagArrayBuilder<ObserverFrameEvent>.frame(frame: String) = addUnique(FrameTag.assemble(frame))

View File

@@ -0,0 +1,35 @@
/*
* 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.quartz.buzz.aoObserver
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
/** The frame recipient pubkey — the single `p` tag (owner for telemetry, agent for control). */
fun TagArray.observerRecipient() = firstNotNullOfOrNull(PTag::parseKey)
/** The agent this observer stream belongs to — the `agent` tag. */
fun TagArray.observerAgent() = firstNotNullOfOrNull(AgentTag::parse)
/** The cleartext frame direction — the `frame` tag (`telemetry` or `control`). */
fun TagArray.observerFrame() = firstNotNullOfOrNull(FrameTag::parse)

View File

@@ -0,0 +1,56 @@
/*
* 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.quartz.buzz.aoObserver.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The Buzz `frame` tag — the cleartext direction of an agent observer frame
* (NIP-AO `kind:24200`). It is the only routing hint a relay reads without
* touching the NIP-44 ciphertext:
* - [TELEMETRY] — agent-to-owner telemetry (author is the agent, `p` is the owner).
* - [CONTROL] — owner-to-agent control commands (author is the owner, `p` is the agent).
*
* Ground truth: `buzz-core/src/observer.rs` (`OBSERVER_FRAME_TAG`,
* `OBSERVER_FRAME_TELEMETRY`, `OBSERVER_FRAME_CONTROL`).
*/
object FrameTag {
const val TAG_NAME = "frame"
/** Agent-to-owner telemetry frame value. */
const val TELEMETRY = "telemetry"
/** Owner-to-agent control frame value. */
const val CONTROL = "control"
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Tag): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(frame: String) = arrayOf(TAG_NAME, frame)
}

View File

@@ -0,0 +1,74 @@
/*
* 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.quartz.buzz.apPersonas
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* The JSON body stored in a Buzz Persona event ([PersonaEvent], NIP-AP `kind:30175`).
*
* This is the on-the-wire projection published by the workspace owner — ground truth is
* `PersonaEventContent` in Buzz's `desktop/src-tauri/src/managed_agents/persona_events.rs`
* (NOT `PersonaConfig` in `buzz-persona/src/persona.rs`, which is the local `.persona.md`
* YAML parser and is never serialized to a Nostr event). Field declaration order is fixed
* upstream because serde emits in order and that order pins the content bytes (and thus the
* NIP-01 event id + the persona content hash); it is mirrored here.
*
* [displayName] is required; every other field is optional. Wire field names are snake_case
* (the Rust struct carries no `rename_all`, so the raw identifiers are the wire names) and are
* mapped with [SerialName]. [respondTo] is a free-form string here (the persona's default
* author-gate mode), unlike the managed-agent projection which uses a closed enum. Unknown JSON
* fields are ignored for forward compatibility.
*/
@Serializable
data class PersonaContent(
@SerialName("display_name") val displayName: String,
@SerialName("system_prompt") val systemPrompt: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
val runtime: String? = null,
val model: String? = null,
val provider: String? = null,
// NEVER-encode the empty defaults to match Rust's `skip_serializing_if = Vec::is_empty`
// (persona_events.rs): emitting `[]` where upstream omits the field would shift the
// content bytes and spuriously trip the desktop's persona_content_hash drift check.
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("name_pool") val namePool: List<String> = emptyList(),
@SerialName("respond_to") val respondTo: String? = null,
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("respond_to_allowlist") val respondToAllowlist: List<String> = emptyList(),
val parallelism: Int? = null,
) {
fun encodeToJson(): String = JSON.encodeToString(this)
companion object {
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun decodeFromJson(json: String): PersonaContent = JSON.decodeFromString(json)
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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.quartz.buzz.apPersonas
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* A Buzz Agent Persona (NIP-AP, `kind:30175`): an addressable, world-readable persona
* definition published by the workspace owner. Addressed by `(pubkey, 30175, d)` where the
* `d` tag is the plaintext persona slug (grammar `^[a-z0-9][a-z0-9_-]{0,63}$`, enforced by
* the relay in `buzz-relay/src/handlers/ingest.rs::validate_persona_envelope`).
*
* The `content` is a plaintext JSON [PersonaContent]. Ground truth for the content projection
* is `desktop/src-tauri/src/managed_agents/persona_events.rs`.
*/
@Immutable
class PersonaEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The persona slug — the `d` tag. */
fun slug() = dTag()
/** Parses the persona configuration, or throws if the JSON is malformed. */
fun persona(): PersonaContent = PersonaContent.decodeFromJson(content)
fun personaOrNull(): PersonaContent? =
try {
persona()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
const val KIND = 30175
fun build(
persona: PersonaContent,
slug: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PersonaEvent>.() -> Unit = {},
) = eventTemplate<PersonaEvent>(KIND, persona.encodeToJson(), createdAt) {
dTag(slug)
initializer()
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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.quartz.buzz.audit
/**
* The action recorded by a Buzz audit entry, mirroring `AuditAction` in
* `buzz-audit/src/action.rs` (snake_case wire values). Unknown values map to [UNKNOWN] so a
* future action string round-trips rather than being dropped.
*/
enum class AuditAction(
val code: String,
) {
EVENT_CREATED("event_created"),
EVENT_DELETED("event_deleted"),
CHANNEL_CREATED("channel_created"),
CHANNEL_UPDATED("channel_updated"),
CHANNEL_DELETED("channel_deleted"),
MEMBER_ADDED("member_added"),
MEMBER_REMOVED("member_removed"),
AUTH_SUCCESS("auth_success"),
AUTH_FAILURE("auth_failure"),
RATE_LIMIT_EXCEEDED("rate_limit_exceeded"),
MEDIA_UPLOADED("media_uploaded"),
/** An unrecognized action string from a future/extended implementation. */
UNKNOWN("unknown"),
;
companion object {
fun fromWire(value: String): AuditAction = entries.firstOrNull { it.code == value } ?: UNKNOWN
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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.quartz.buzz.audit
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz audit-log entry (`kind:48001`): a record of one moderated/administrative action.
*
* SCHEMA INFERRED — FLAGGED. The Rust source (`buzz-core/src/kind.rs`) reserves this kind and
* lists it in a metrics-label allowlist (`buzz-relay/src/handlers/event.rs::bounded_kind_label`),
* but the codebase NEVER constructs a `kind:48001` Nostr event: the audit trail is a
* server-side hash-chained DB structure (`buzz-audit/src/entry.rs::AuditEntry`), not a wire
* event. The tag/content mapping below is a Quartz-side projection of that struct — `content`
* holds the arbitrary JSON `detail`, an `action` tag holds the [AuditAction], an optional `p`
* tag names the actor, and an optional `object` tag holds the object id. Do NOT assume this is
* registrable or interoperable until the Buzz side actually emits this kind on the wire.
*/
@Immutable
class AuditEntryEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The recorded action — the `action` tag. */
fun action(): AuditAction? = tags.auditAction()
/** The actor pubkey — the `p` tag, if the action has one. */
fun actor(): HexKey? = tags.auditActor()
/** The identifier of the object acted upon — the `object` tag, if any. */
fun objectId(): String? = tags.auditObjectId()
/** The arbitrary JSON `detail` context — the event `content`. */
fun detail(): String = content
companion object {
const val KIND = 48001
fun build(
action: AuditAction,
detail: String,
actorPubKey: HexKey? = null,
objectId: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AuditEntryEvent>.() -> Unit = {},
) = eventTemplate<AuditEntryEvent>(KIND, detail, createdAt) {
action(action)
actorPubKey?.let { actor(it) }
objectId?.let { objectId(it) }
initializer()
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.quartz.buzz.audit
import com.vitorpamplona.quartz.buzz.audit.tags.ActionTag
import com.vitorpamplona.quartz.buzz.audit.tags.ObjectTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArrayBuilder<AuditEntryEvent>.action(action: AuditAction) = addUnique(ActionTag.assemble(action))
fun TagArrayBuilder<AuditEntryEvent>.actor(actorPubKey: HexKey) = addUnique(PTag.assemble(actorPubKey, null))
fun TagArrayBuilder<AuditEntryEvent>.objectId(objectId: String) = addUnique(ObjectTag.assemble(objectId))

View File

@@ -0,0 +1,35 @@
/*
* 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.quartz.buzz.audit
import com.vitorpamplona.quartz.buzz.audit.tags.ActionTag
import com.vitorpamplona.quartz.buzz.audit.tags.ObjectTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
/** The audit action — the `action` tag. */
fun TagArray.auditAction() = firstNotNullOfOrNull(ActionTag::parse)
/** The actor pubkey — the `p` tag. */
fun TagArray.auditActor() = firstNotNullOfOrNull(PTag::parseKey)
/** The identifier of the object acted upon — the `object` tag. */
fun TagArray.auditObjectId() = firstNotNullOfOrNull(ObjectTag::parse)

View File

@@ -0,0 +1,47 @@
/*
* 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.quartz.buzz.audit.tags
import com.vitorpamplona.quartz.buzz.audit.AuditAction
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The Buzz `action` tag — the audit action recorded on an
* [com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent]. SCHEMA INFERRED (see the event's
* KDoc): the Rust never constructs a `kind:48001` event, so this tag's name is a Quartz-side
* choice made to expose the `AuditAction` (`buzz-audit/src/action.rs`) at the tag level.
*/
object ActionTag {
const val TAG_NAME = "action"
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Tag): AuditAction? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return AuditAction.fromWire(tag[1])
}
fun assemble(action: AuditAction) = arrayOf(TAG_NAME, action.code)
}

View File

@@ -0,0 +1,45 @@
/*
* 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.quartz.buzz.audit.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The Buzz `object` tag — the generic identifier of the object an audit entry acted upon
* (event id hex, channel UUID, media sha256, …), mirroring `AuditEntry.object_id` in
* `buzz-audit/src/entry.rs`. SCHEMA INFERRED (see [com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent]).
*/
object ObjectTag {
const val TAG_NAME = "object"
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Tag): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(objectId: String) = arrayOf(TAG_NAME, objectId)
}

View File

@@ -0,0 +1,56 @@
/*
* 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.quartz.buzz.cwChannelWindow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* The JSON `content` of a Buzz NIP-CW thread-summary overlay (`kind:39005`).
*
* Field names are snake_case on the wire, mirroring the object the relay synthesizes in
* `buzz-relay/src/api/bridge.rs`: [replyCount] direct replies, [descendantCount] total
* events in the thread, [lastReplyAt] the newest reply's unix-seconds timestamp (null when
* there are no replies), and [participants] the hex pubkeys that posted in the thread.
*/
@Serializable
data class ThreadSummaryContent(
@SerialName("reply_count") val replyCount: Long,
@SerialName("descendant_count") val descendantCount: Long,
@SerialName("last_reply_at") val lastReplyAt: Long? = null,
val participants: List<String> = emptyList(),
) {
fun encodeToJson(): String = JSON.encodeToString(this)
companion object {
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun decodeFromJson(json: String): ThreadSummaryContent = JSON.decodeFromString(json)
}
}

View File

@@ -0,0 +1,85 @@
/*
* 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.quartz.buzz.cwChannelWindow
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz NIP-CW thread-summary overlay (`kind:39005`), **signed by the relay** and
* synthesized at query time (never stored). Appended to a bridge window response, one per
* root event that has replies. Addressable with `d` = the root event id; it also `e`-tags
* the same root and `h`-tags the channel. `content` is a [ThreadSummaryContent] JSON blob.
* Clients never publish this kind; [build] exists for fixtures/tests. Ground truth:
* `buzz-relay/src/api/bridge.rs` (thread-summary overlays).
*/
@Immutable
class ThreadSummaryEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The thread root event id — the `d` tag (equal to the `e` tag). */
fun rootId() = dTag()
/** The channel id — the `h` tag. */
fun channelId() = tags.firstNotNullOfOrNull(GroupIdTag::parse)
/** The root event id from the `e` tag. */
fun rootEventTag() = tags.firstNotNullOfOrNull(ETag::parseId)
/** Parses the JSON summary [content]. Throws on malformed content; use [summaryOrNull]. */
fun summary(): ThreadSummaryContent = ThreadSummaryContent.decodeFromJson(content)
fun summaryOrNull(): ThreadSummaryContent? =
try {
summary()
} catch (_: Exception) {
null
}
companion object {
const val KIND = 39005
fun build(
rootId: HexKey,
channelId: String,
summary: ThreadSummaryContent,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ThreadSummaryEvent>.() -> Unit = {},
) = eventTemplate<ThreadSummaryEvent>(KIND, summary.encodeToJson(), createdAt) {
addUnique(ETag.assemble(rootId, null, null))
dTag(rootId)
addUnique(GroupIdTag.assemble(channelId))
initializer()
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* 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.quartz.buzz.cwChannelWindow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* The JSON `content` of a Buzz NIP-CW window-bounds overlay (`kind:39006`) — the sole
* authority on window exhaustion. Mirrors `buzz-relay/src/api/bridge.rs`: [hasMore] is
* whether more rows exist beyond this page, and [nextCursor] is the cursor to request them
* (null when exhausted). Clients MUST NOT infer `has_more` from row counts.
*/
@Serializable
data class WindowBoundsContent(
@SerialName("has_more") val hasMore: Boolean,
@SerialName("next_cursor") val nextCursor: NextCursor? = null,
) {
fun encodeToJson(): String = JSON.encodeToString(this)
companion object {
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun decodeFromJson(json: String): WindowBoundsContent = JSON.decodeFromString(json)
}
}
/** A window pagination cursor: the [createdAt] unix-seconds timestamp and hex event [id] of the boundary row. */
@Serializable
data class NextCursor(
@SerialName("created_at") val createdAt: Long,
val id: String,
)

View File

@@ -0,0 +1,86 @@
/*
* 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.quartz.buzz.cwChannelWindow
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz NIP-CW window-bounds overlay (`kind:39006`), **signed by the relay** and
* synthesized at query time (never stored). Exactly one is appended per window response and
* it is the only authority on exhaustion. Addressable with `d` = `<channel_id>:<cursor|head>`
* and `h`-tags the channel; `content` is a [WindowBoundsContent] JSON blob. Clients never
* publish this kind; [build] exists for fixtures/tests. Ground truth:
* `buzz-relay/src/api/bridge.rs` (window bounds).
*/
@Immutable
class WindowBoundsEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The window key — the `d` tag, formatted `<channel_id>:<cursor-or-head>`. */
fun windowKey() = dTag()
/** The channel id — the `h` tag. */
fun channelId() = tags.firstNotNullOfOrNull(GroupIdTag::parse)
/** Parses the JSON bounds [content]. Throws on malformed content; use [boundsOrNull]. */
fun bounds(): WindowBoundsContent = WindowBoundsContent.decodeFromJson(content)
fun boundsOrNull(): WindowBoundsContent? =
try {
bounds()
} catch (_: Exception) {
null
}
companion object {
const val KIND = 39006
/** The `d`-tag value for a window: `<channelId>:<cursor>`, where `cursor` is `head` for the first page. */
fun windowKey(
channelId: String,
cursor: String = "head",
) = "$channelId:$cursor"
fun build(
channelId: String,
bounds: WindowBoundsContent,
cursor: String = "head",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<WindowBoundsEvent>.() -> Unit = {},
) = eventTemplate<WindowBoundsEvent>(KIND, bounds.encodeToJson(), createdAt) {
dTag(windowKey(channelId, cursor))
addUnique(GroupIdTag.assemble(channelId))
initializer()
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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.quartz.buzz.dm
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz "add member to DM" command (`kind:41011`): adds a new participant to an
* existing group DM. Scopes the target conversation with the `h` (NIP-29 group id =
* DM channel UUID) tag and names the invited member with a single `p` tag; the
* `content` is empty.
*
* Ground truth: `buzz-sdk/src/builders.rs` (`build_dm_add_member`) and
* `buzz-cli/src/commands/dms.rs` (`cmd_add_dm_member`).
*/
@Immutable
class DmAddMemberEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The target DM channel id - the `h` tag. */
fun channelId() = tags.dmChannelId()
/** The member being added - the single `p` tag. */
fun member() = tags.dmParticipants().firstOrNull()
companion object {
const val KIND = 41011
fun build(
channelId: String,
member: HexKey,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DmAddMemberEvent>.() -> Unit = {},
) = eventTemplate<DmAddMemberEvent>(KIND, "", createdAt) {
dmChannel(channelId)
dmMember(member)
initializer()
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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.quartz.buzz.dm
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz "DM created" confirmation (`kind:41001`): the **relay-signed** record of a
* materialized DM conversation. Clients list their DMs by querying `kind:41001`
* filtered by their own `#p`; each event carries the DM id in a `d` tag and one `p`
* tag per participant.
*
* This is a relay-authored sidecar, not a client-published command - clients react to
* [DmOpenEvent]/[DmAddMemberEvent] by emitting the command, and the relay confirms
* with this event. [build] is provided for tests/tooling parity; in production the
* relay is the signer. Ground truth: `buzz-cli/src/commands/dms.rs` (`cmd_list_dms`,
* which reads the `d` tag as `dm_id` and the `p` tags as participants) and
* `buzz-core/src/kind.rs` (`KIND_DM_CREATED`).
*/
@Immutable
class DmCreatedEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The DM id - the `d` tag (empty string when absent). */
fun dmId() = tags.dmId()
/** The DM participants - one per `p` tag. */
fun participants() = tags.dmParticipants()
companion object {
const val KIND = 41001
fun build(
dmId: String,
participants: List<HexKey>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DmCreatedEvent>.() -> Unit = {},
) = eventTemplate<DmCreatedEvent>(KIND, "", createdAt) {
dmId(dmId)
participants(participants)
initializer()
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.quartz.buzz.dm
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz "hide DM" command (`kind:41012`): hides a DM conversation from the author's
* sidebar. Carries only the `h` (NIP-29 group id = DM channel UUID) tag scoping the
* conversation to hide; the `content` is empty.
*
* Ground truth: `buzz-cli/src/commands/dms.rs` (`cmd_hide_dm`) and
* `buzz-relay/src/handlers/command_executor.rs` (`handle_dm_hide`).
*/
@Immutable
class DmHideEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The DM channel id being hidden - the `h` tag. */
fun channelId() = tags.dmChannelId()
companion object {
const val KIND = 41012
fun build(
channelId: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DmHideEvent>.() -> Unit = {},
) = eventTemplate<DmHideEvent>(KIND, "", createdAt) {
dmChannel(channelId)
initializer()
}
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.quartz.buzz.dm
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz "open DM" command (`kind:41010`): opens (or re-opens) a direct-message
* conversation between the author and 1-8 named participants. The participants are
* carried as one `p` tag each and the `content` is empty - the relay executes the
* command, materializes the DM channel, and confirms it back with a relay-signed
* [DmCreatedEvent] (`kind:41001`).
*
* Ground truth: `buzz-sdk/src/builders.rs` (`build_dm_open`) and
* `buzz-relay/src/handlers/command_executor.rs`.
*/
@Immutable
class DmOpenEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The DM participants - one per `p` tag. */
fun participants() = tags.dmParticipants()
companion object {
const val KIND = 41010
/** Buzz enforces 1-8 participants for a DM open command. */
const val MIN_PARTICIPANTS = 1
const val MAX_PARTICIPANTS = 8
fun build(
participants: List<HexKey>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DmOpenEvent>.() -> Unit = {},
): EventTemplate<DmOpenEvent> {
require(participants.size in MIN_PARTICIPANTS..MAX_PARTICIPANTS) {
"A DM open requires $MIN_PARTICIPANTS-$MAX_PARTICIPANTS participants (got ${participants.size})"
}
return eventTemplate(KIND, "", createdAt) {
participants(participants)
initializer()
}
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.quartz.buzz.dm
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
/** One `p` tag per DM participant. */
fun <T : Event> TagArrayBuilder<T>.participants(pubKeys: List<HexKey>) = addAll(pubKeys.map { PTag.assemble(it, null) })
/** The `h` (NIP-29 group id = DM channel UUID) tag. */
fun <T : Event> TagArrayBuilder<T>.dmChannel(channelId: String) = addUnique(GroupIdTag.assemble(channelId))
/** A single `p` tag for the member being added. */
fun <T : Event> TagArrayBuilder<T>.dmMember(pubKey: HexKey) = addUnique(PTag.assemble(pubKey, null))
/** The `d` tag carrying the DM id (relay-signed [DmCreatedEvent]). */
fun <T : Event> TagArrayBuilder<T>.dmId(dmId: String) = addUnique(DTag.assemble(dmId))

View File

@@ -0,0 +1,36 @@
/*
* 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.quartz.buzz.dm
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
/** All DM participant pubkeys - every `p` tag. */
fun TagArray.dmParticipants() = mapNotNull(PTag::parseKey)
/** The DM channel id - the first `h` tag. */
fun TagArray.dmChannelId() = firstNotNullOfOrNull(GroupIdTag::parse)
/** The DM id - the `d` tag value (empty string when absent). */
fun TagArray.dmId() = firstTagValue(DTag.TAG_NAME) ?: ""

View File

@@ -0,0 +1,79 @@
/*
* 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.quartz.buzz.dvDmVisibility
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz per-viewer DM Visibility snapshot (NIP-DV, `kind:30622`): a relay-signed,
* addressable event that projects one viewer's hidden-DM set. It is keyed by the viewer's
* pubkey (the `d` tag), carries a matching `p` tag so the relay's `#p`-gated read path
* scopes it to its owner, and lists one `h` tag per hidden DM channel (zero or more). The
* content is empty; the relay re-publishes the whole snapshot on every hide/unhide.
*
* The event is signed by the relay keypair, not the viewer. Ground truth:
* `buzz-core/src/kind.rs` (`KIND_DM_VISIBILITY`) and
* `buzz-relay/src/handlers/side_effects.rs` (`publish_dm_visibility_snapshot`).
*/
@Immutable
class DmVisibilityEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The viewer this snapshot belongs to — the `d` tag (equal to the `p` tag). */
fun viewer() = dTag()
/** The viewer pubkey read from the `p` tag. */
fun viewerFromPTag() = tags.dmVisibilityViewer()
/** The channel ids the viewer has hidden — the `h` tags (possibly empty). */
fun hiddenChannels() = tags.dmVisibilityHiddenChannels()
companion object {
const val KIND = 30622
/**
* Builds a DM-visibility snapshot template for [viewerPubKey] listing
* [hiddenChannels]. The content is empty. Sign it with the relay keypair.
*/
fun build(
viewerPubKey: HexKey,
hiddenChannels: List<String>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DmVisibilityEvent>.() -> Unit = {},
) = eventTemplate<DmVisibilityEvent>(KIND, "", createdAt) {
dTag(viewerPubKey)
viewer(viewerPubKey)
hiddenChannels(hiddenChannels)
initializer()
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.quartz.buzz.dvDmVisibility
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
fun TagArrayBuilder<DmVisibilityEvent>.viewer(viewerPubKey: HexKey) = addUnique(PTag.assemble(viewerPubKey, null))
fun TagArrayBuilder<DmVisibilityEvent>.hiddenChannel(channelId: String) = add(GroupIdTag.assemble(channelId))
fun TagArrayBuilder<DmVisibilityEvent>.hiddenChannels(channelIds: List<String>) = addAll(channelIds.map(GroupIdTag::assemble))

View File

@@ -0,0 +1,31 @@
/*
* 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.quartz.buzz.dvDmVisibility
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
/** The viewer pubkey from the `p` tag, or null if absent. */
fun TagArray.dmVisibilityViewer() = firstNotNullOfOrNull(PTag::parseKey)
/** The hidden DM channel ids — every `h` tag (possibly empty). */
fun TagArray.dmVisibilityHiddenChannels() = mapNotNull(GroupIdTag::parse)

View File

@@ -0,0 +1,117 @@
/*
* 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.quartz.buzz.erReminders
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* A Buzz Event Reminder (NIP-ER, `kind:30300`): an encrypted, author-only, addressable
* reminder keyed by `(pubkey, 30300, d)`.
*
* The public [notBefore] tag tells supporting relays when the reminder is due; the
* reminder [target], `note`, and `status` are NIP-44 encrypted to the author's own
* public key (the same self-encryption pattern as NIP-51 private lists), so [decrypt]
* needs the author's signer. A reminder without `not_before` is a bookmark or a terminal
* (done/cancelled) state. Ground truth: `buzz-core/src/kind.rs` (`KIND_EVENT_REMINDER`)
* and `buzz-relay/src/handlers/ingest.rs` (`validate_event_reminder`).
*/
@Immutable
class EventReminderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
/** The public due-time hint (unix seconds), or null for bookmarks/terminal states. */
fun notBefore() = tags.reminderNotBefore()
/** NIP-40 cleanup expiration (unix seconds), when set. */
fun expiration() = tags.expiration()
/**
* Decrypts and parses the reminder payload. [signer] MUST be the author (this event's
* own key), because the content is self-encrypted. Throws on a decryption failure or a
* malformed payload; use [decryptOrNull] to swallow those.
*/
suspend fun decrypt(signer: NostrSigner): EventReminderPayload {
val json = signer.decrypt(content, pubKey)
return EventReminderPayload.decodeFromJson(json)
}
suspend fun decryptOrNull(signer: NostrSigner): EventReminderPayload? =
try {
decrypt(signer)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
const val KIND = 30300
const val ALT_DESCRIPTION = "Encrypted reminder"
/**
* Builds and signs a reminder: [signer] is the author; [payload] is NIP-44
* self-encrypted to the author's own key. [identifier] is the addressable `d`
* tag and MUST be an opaque random value (>= 128 bits of entropy).
*/
suspend fun create(
payload: EventReminderPayload,
identifier: HexKey,
signer: NostrSigner,
notBefore: Long? = null,
expiration: Long? = null,
createdAt: Long = TimeUtils.now(),
): EventReminderEvent {
val ciphertext = signer.nip44Encrypt(payload.encodeToJson(), signer.pubKey)
return signer.sign(build(ciphertext, identifier, notBefore, expiration, createdAt))
}
fun build(
ciphertext: String,
identifier: String,
notBefore: Long? = null,
expiration: Long? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EventReminderEvent>.() -> Unit = {},
) = eventTemplate<EventReminderEvent>(KIND, ciphertext, createdAt) {
dTag(identifier)
notBefore?.let { notBefore(it) }
expiration?.let { expiration(it) }
alt(ALT_DESCRIPTION)
initializer()
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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.quartz.buzz.erReminders
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* The decrypted plaintext of a Buzz Event Reminder (NIP-ER, `kind:30300`).
*
* Field names mirror the NIP-ER content object exactly (all lowercase on the wire).
* A pending reminder MUST carry either a [target] reference or a non-empty [note];
* both are optional here so a note-only or a target-only reminder round-trips. Unknown
* JSON fields are ignored for forward compatibility, and an unrecognized [status] value
* decodes to null via [statusOrNull] rather than dropping the payload.
*/
@Serializable
data class EventReminderPayload(
val target: ReminderTarget? = null,
val status: String,
val note: String? = null,
) {
/** Maps the wire [status] to a [ReminderStatus], or null if unrecognized. */
fun statusOrNull(): ReminderStatus? = ReminderStatus.fromWire(status)
fun encodeToJson(): String = JSON.encodeToString(this)
companion object {
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun decodeFromJson(json: String): EventReminderPayload = JSON.decodeFromString(json)
}
}
/**
* A reference to what the reminder is about. Every field is optional: `a` is the preferred
* addressable coordinate, `id` a snapshot fallback event id, `relays` are hints only, and
* `preview` a cached label. Mirrors the `target` object in NIP-ER content.
*/
@Serializable
data class ReminderTarget(
val id: String? = null,
val a: String? = null,
val relays: List<String>? = null,
val preview: String? = null,
)
/** Reminder lifecycle status. Wire values are lowercase; unknown decodes to null. */
enum class ReminderStatus {
@SerialName("pending")
PENDING,
@SerialName("done")
DONE,
@SerialName("cancelled")
CANCELLED,
;
companion object {
fun fromWire(value: String): ReminderStatus? =
when (value) {
"pending" -> PENDING
"done" -> DONE
"cancelled" -> CANCELLED
else -> null
}
}
}

View File

@@ -0,0 +1,26 @@
/*
* 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.quartz.buzz.erReminders
import com.vitorpamplona.quartz.buzz.erReminders.tags.NotBeforeTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<EventReminderEvent>.notBefore(unixSeconds: Long) = addUnique(NotBeforeTag.assemble(unixSeconds))

View File

@@ -0,0 +1,27 @@
/*
* 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.quartz.buzz.erReminders
import com.vitorpamplona.quartz.buzz.erReminders.tags.NotBeforeTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
/** The public `not_before` due-time hint (unix seconds), or null if none is present. */
fun TagArray.reminderNotBefore() = firstNotNullOfOrNull(NotBeforeTag::parse)

View File

@@ -0,0 +1,46 @@
/*
* 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.quartz.buzz.erReminders.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The Buzz `not_before` tag (NIP-ER) — the public due-time hint of an encrypted reminder,
* a decimal Unix-seconds timestamp. A supporting relay reads it to schedule the due signal
* without decrypting the reminder. At most one is allowed; terminal states and bookmarks
* omit it. Ground truth: `buzz-relay/src/handlers/ingest.rs` (`validate_not_before`).
*/
object NotBeforeTag {
const val TAG_NAME = "not_before"
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Tag): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(unixSeconds: Long) = arrayOf(TAG_NAME, unixSeconds.toString())
}

View File

@@ -0,0 +1,82 @@
/*
* 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.quartz.buzz.forum
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A comment reply on a Buzz forum post (`kind:45003`). The `content` is the reply body,
* an `h` tag scopes it to the channel, NIP-10 `e` tags (`root`/`reply` markers) place it
* in the thread, and optional `p` tags mention members. Ground truth: `build_forum_comment`
* + `thread_tags` in Buzz's `buzz-sdk/src/builders.rs`.
*/
@Immutable
class ForumCommentEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The channel UUID (the `h` tag) this comment belongs to. */
fun channel() = tags.forumChannel()
/** The thread root event id (`root`-marked `e` tag), or null for a direct reply. */
fun threadRoot() = tags.forumThreadRoot()
/** The immediate-parent event id (`reply`-marked `e` tag). */
fun replyTo() = tags.forumThreadReply()
/** The pubkeys mentioned by this comment (`p` tags). */
fun mentions() = tags.forumMentions()
/** The comment body - the event `content`. */
fun body() = content
companion object {
const val KIND = 45003
/**
* Builds a forum comment. When [rootEventId] equals [parentEventId] the comment is a
* direct reply to the thread root; otherwise it is a nested reply - see [forumThread].
*/
fun build(
channelId: String,
body: String,
rootEventId: HexKey,
parentEventId: HexKey,
mentions: List<HexKey> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ForumCommentEvent>.() -> Unit = {},
) = eventTemplate<ForumCommentEvent>(KIND, body, createdAt) {
forumChannel(channelId)
forumThread(rootEventId, parentEventId)
forumMentions(mentions)
initializer()
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.quartz.buzz.forum
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz forum post - the root of a forum thread (`kind:45001`). The `content` is the
* post body (plaintext, max 64 KiB). An `h` tag scopes it to a channel; optional `p`
* tags mention channel members. Ground truth: `build_forum_post` in Buzz's
* `buzz-sdk/src/builders.rs`.
*/
@Immutable
class ForumPostEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The channel UUID (the `h` tag) this post belongs to. */
fun channel() = tags.forumChannel()
/** The pubkeys mentioned by this post (`p` tags). */
fun mentions() = tags.forumMentions()
/** The post body - the event `content`. */
fun body() = content
companion object {
const val KIND = 45001
fun build(
channelId: String,
body: String,
mentions: List<HexKey> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ForumPostEvent>.() -> Unit = {},
) = eventTemplate<ForumPostEvent>(KIND, body, createdAt) {
forumChannel(channelId)
forumMentions(mentions)
initializer()
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.quartz.buzz.forum
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.buzz.forum.tags.VoteDirection
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A vote on a Buzz forum post (`kind:45002`). The `content` is `"+"` (up) or `"-"` (down),
* an `h` tag scopes it to the channel, and an `e` tag names the target event. Ground truth:
* `build_vote` in Buzz's `buzz-sdk/src/builders.rs`.
*/
@Immutable
class ForumVoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The channel UUID (the `h` tag) this vote belongs to. */
fun channel() = tags.forumChannel()
/** The `e` target event this vote applies to. */
fun target() = tags.forumVoteTarget()
/** The vote direction parsed from `content` (`"+"`/`"-"`), or null if malformed. */
fun direction() = VoteDirection.fromContent(content)
companion object {
const val KIND = 45002
fun build(
channelId: String,
targetEventId: HexKey,
direction: VoteDirection,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ForumVoteEvent>.() -> Unit = {},
) = eventTemplate<ForumVoteEvent>(KIND, direction.code, createdAt) {
forumChannel(channelId)
forumVoteTarget(targetEventId)
initializer()
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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.quartz.buzz.forum
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
/** The `h` channel tag (NIP-29) scoping a Buzz forum event to its channel UUID. */
fun <T : Event> TagArrayBuilder<T>.forumChannel(channelId: String) = addUnique(GroupIdTag.assemble(channelId))
/** A `p` mention per pubkey, deduplicated. Mirrors `mention_tags` in `builders.rs`. */
fun <T : Event> TagArrayBuilder<T>.forumMentions(mentions: List<HexKey>) = mentions.forEach { addUniqueValueIfNew(PTag.assemble(it, null)) }
/** The `e` tag naming a forum vote's target event. */
fun <T : Event> TagArrayBuilder<T>.forumVoteTarget(targetEventId: HexKey) = addUnique(ETag.assemble(targetEventId, null, null))
/**
* NIP-10 thread e-tags for a forum comment. Buzz threads streams and forum comments
* identically (`thread_tags` in `builders.rs`), so this delegates to the shared
* [buzzThread] verb.
*/
fun <T : Event> TagArrayBuilder<T>.forumThread(
rootEventId: HexKey,
parentEventId: HexKey,
) = buzzThread(rootEventId, parentEventId)

View File

@@ -0,0 +1,45 @@
/*
* 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.quartz.buzz.forum
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
/** The `h` channel UUID this forum event belongs to. */
fun TagArray.forumChannel(): String? = firstTagValue(GroupIdTag.TAG_NAME)
/** The `p` mentions carried by a forum post/comment. */
fun TagArray.forumMentions(): List<HexKey> = mapNotNull(PTag::parseKey)
/** The `e` target event of a forum vote (`kind:45002`). */
fun TagArray.forumVoteTarget(): HexKey? = firstNotNullOfOrNull(ETag::parseId)
/** The NIP-10 `root`-marked thread event id of a forum comment (null for a direct reply). */
fun TagArray.forumThreadRoot(): HexKey? = buzzThreadRoot()
/** The NIP-10 `reply`-marked immediate-parent event id of a forum comment. */
fun TagArray.forumThreadReply(): HexKey? = buzzThreadReply()

View File

@@ -0,0 +1,44 @@
/*
* 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.quartz.buzz.forum.tags
/**
* The direction of a Buzz forum vote (`kind:45002`). The vote's on-the-wire form is the
* event `content`: `"+"` for [UP], `"-"` for [DOWN]. Ground truth: `build_vote` in Buzz's
* `buzz-sdk/src/builders.rs`.
*/
enum class VoteDirection(
val code: String,
) {
UP("+"),
DOWN("-"),
;
companion object {
/** Parses the vote [content] (`"+"`/`"-"`) into a [VoteDirection], or null if unrecognized. */
fun fromContent(content: String): VoteDirection? =
when (content) {
UP.code -> UP
DOWN.code -> DOWN
else -> null
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.quartz.buzz.huddles
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz "huddle ended" event (`kind:48103`): the audio session ended (typically emitted
* relay-signed when the room empties and auto-archives). Scoped to the parent channel by an
* `h` tag; `content` names the ephemeral audio channel, and a `p` tag names the last
* participant. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
*/
@Immutable
class HuddleEndedEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The parent (timeline) channel UUID — the `h` tag. */
fun channelId(): String? = tags.huddleChannel()
/** The participant named on the ending event — the `p` tag, if present. */
fun participant(): HexKey? = tags.huddleParticipant()
/** The ephemeral audio channel id carried in `content`. */
fun ephemeralChannelId(): String? = HuddleLifecycleContent.decodeFromJsonOrNull(content)?.ephemeralChannelId
companion object {
const val KIND = 48103
fun build(
channelId: String,
ephemeralChannelId: String,
participant: HexKey? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HuddleEndedEvent>.() -> Unit = {},
) = eventTemplate<HuddleEndedEvent>(KIND, HuddleLifecycleContent(ephemeralChannelId).encodeToJson(), createdAt) {
huddleChannel(channelId)
participant?.let { huddleParticipant(it) }
initializer()
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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.quartz.buzz.huddles
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz huddle/channel guidelines document (`kind:48106`): the rules text that agents in a
* channel are steered by (system messages moved to this kind from inline `[System]`-prefixed
* chat). Scoped to the channel by an `h` tag; the guidelines text lives in `content`.
*
* SCHEMA UNCERTAIN: the Rust source only wires this kind's scope/validation
* (`buzz-relay/src/handlers/ingest.rs` — ChannelsWrite, `h` required); it never constructs a
* `kind:48106` event, so the exact content shape (plain text vs JSON) is inferred from the
* TTS reference in `desktop/.../useTtsSubscription.ts`. Treated here as plain-text `content`.
*/
@Immutable
class HuddleGuidelinesEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The channel UUID these guidelines apply to — the `h` tag. */
fun channelId(): String? = tags.huddleChannel()
/** The guidelines text — the event `content`. */
fun guidelines(): String = content
companion object {
const val KIND = 48106
fun build(
channelId: String,
guidelines: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HuddleGuidelinesEvent>.() -> Unit = {},
) = eventTemplate<HuddleGuidelinesEvent>(KIND, guidelines, createdAt) {
huddleChannel(channelId)
initializer()
}
}
}

View File

@@ -0,0 +1,58 @@
/*
* 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.quartz.buzz.huddles
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* The `content` JSON shared by every Buzz huddle-lifecycle event (`kind:48100``48103`):
* the id of the ephemeral audio channel the lifecycle transition applies to. The parent
* (timeline) channel is carried separately in the `h` tag. Field name is snake_case on
* the wire. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
*/
@Serializable
data class HuddleLifecycleContent(
@SerialName("ephemeral_channel_id") val ephemeralChannelId: String,
) {
fun encodeToJson(): String = JSON.encodeToString(this)
companion object {
val JSON =
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun decodeFromJson(json: String): HuddleLifecycleContent = JSON.decodeFromString(json)
fun decodeFromJsonOrNull(json: String): HuddleLifecycleContent? =
try {
decodeFromJson(json)
} catch (_: Exception) {
null
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.quartz.buzz.huddles
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz "participant joined" event (`kind:48101`): a participant joined a huddle. Emitted
* relay-signed, so the actual participant is named by the `p` tag (not the event `pubkey`).
* Scoped to the parent channel by an `h` tag; `content` names the ephemeral audio channel.
* Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
*/
@Immutable
class HuddleParticipantJoinedEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The parent (timeline) channel UUID — the `h` tag. */
fun channelId(): String? = tags.huddleChannel()
/** The participant who joined — the `p` tag. */
fun participant(): HexKey? = tags.huddleParticipant()
/** The ephemeral audio channel id carried in `content`. */
fun ephemeralChannelId(): String? = HuddleLifecycleContent.decodeFromJsonOrNull(content)?.ephemeralChannelId
companion object {
const val KIND = 48101
fun build(
channelId: String,
ephemeralChannelId: String,
participant: HexKey,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HuddleParticipantJoinedEvent>.() -> Unit = {},
) = eventTemplate<HuddleParticipantJoinedEvent>(KIND, HuddleLifecycleContent(ephemeralChannelId).encodeToJson(), createdAt) {
huddleChannel(channelId)
huddleParticipant(participant)
initializer()
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.quartz.buzz.huddles
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz "participant left" event (`kind:48102`): a participant left a huddle. Emitted
* relay-signed, so the actual participant is named by the `p` tag (not the event `pubkey`).
* Scoped to the parent channel by an `h` tag; `content` names the ephemeral audio channel.
* Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
*/
@Immutable
class HuddleParticipantLeftEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The parent (timeline) channel UUID — the `h` tag. */
fun channelId(): String? = tags.huddleChannel()
/** The participant who left — the `p` tag. */
fun participant(): HexKey? = tags.huddleParticipant()
/** The ephemeral audio channel id carried in `content`. */
fun ephemeralChannelId(): String? = HuddleLifecycleContent.decodeFromJsonOrNull(content)?.ephemeralChannelId
companion object {
const val KIND = 48102
fun build(
channelId: String,
ephemeralChannelId: String,
participant: HexKey,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HuddleParticipantLeftEvent>.() -> Unit = {},
) = eventTemplate<HuddleParticipantLeftEvent>(KIND, HuddleLifecycleContent(ephemeralChannelId).encodeToJson(), createdAt) {
huddleChannel(channelId)
huddleParticipant(participant)
initializer()
}
}
}

View File

@@ -0,0 +1,79 @@
/*
* 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.quartz.buzz.huddles
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.buzz.huddles.reaction.reaction
import com.vitorpamplona.quartz.buzz.huddles.reaction.reactionChannel
import com.vitorpamplona.quartz.buzz.huddles.reaction.reactionCustomEmojis
import com.vitorpamplona.quartz.buzz.huddles.reaction.reactionSenderName
import com.vitorpamplona.quartz.buzz.huddles.reaction.senderName
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz huddle emoji reaction burst (`kind:24810`): a transient emoji animation shown to
* everyone in an ephemeral huddle channel. Ephemeral (2000029999) — never stored. The emoji
* is in `content` and mirrored in a `reaction` tag; the burst is scoped to the huddle channel
* by an `h` tag, carries a `sender_name` display name, and may carry a NIP-30 `emoji` tag for
* a custom emoji. Ground truth: `desktop/.../huddle/components/HuddleBar.tsx`.
*/
@Immutable
class HuddleReactionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The ephemeral huddle channel UUID — the `h` tag. */
fun channelId(): String? = tags.reactionChannel()
/** The emoji — the `reaction` tag, or the `content` fallback. */
fun emoji(): String = tags.reaction() ?: content
/** The sender's display name — the `sender_name` tag. */
fun senderName(): String? = tags.reactionSenderName()
/** Any NIP-30 custom-emoji descriptors carried on the burst. */
fun customEmojis() = tags.reactionCustomEmojis()
companion object {
const val KIND = 24810
fun build(
channelId: String,
emoji: String,
senderName: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HuddleReactionEvent>.() -> Unit = {},
) = eventTemplate<HuddleReactionEvent>(KIND, emoji, createdAt) {
reactionChannel(channelId)
reaction(emoji)
senderName(senderName)
initializer()
}
}
}

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.quartz.buzz.huddles
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz "huddle started" event (`kind:48100`): the creator announces a new audio
* session in a channel. Signed by the creator (the participant is the event `pubkey`),
* scoped to the parent channel by an `h` tag, with `content` naming the ephemeral audio
* channel. Ground truth: `buzz-relay/src/audio/handler.rs`, `buzz-relay/src/handlers/ingest.rs`.
*/
@Immutable
class HuddleStartedEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The parent (timeline) channel UUID — the `h` tag. */
fun channelId(): String? = tags.huddleChannel()
/** The ephemeral audio channel id carried in `content`. */
fun ephemeralChannelId(): String? = HuddleLifecycleContent.decodeFromJsonOrNull(content)?.ephemeralChannelId
companion object {
const val KIND = 48100
fun build(
channelId: String,
ephemeralChannelId: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HuddleStartedEvent>.() -> Unit = {},
) = eventTemplate<HuddleStartedEvent>(KIND, HuddleLifecycleContent(ephemeralChannelId).encodeToJson(), createdAt) {
huddleChannel(channelId)
initializer()
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.quartz.buzz.huddles
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
/** Scopes a huddle event to its parent (timeline) channel — the `h` tag. */
fun <T : Event> TagArrayBuilder<T>.huddleChannel(channelId: String) = addUnique(GroupIdTag.assemble(channelId))
/** Names the participant a huddle lifecycle event is about — the `p` tag. */
fun <T : Event> TagArrayBuilder<T>.huddleParticipant(pubKey: HexKey) = addUnique(PTag.assemble(pubKey, null))

Some files were not shown because too many files have changed in this diff Show More