mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
[3/4] buzz personas: publish agent definitions from the CLI
Personas could only be created by clicking through Buzz Desktop, so nothing scriptable could stand up an agent roster — including the agents themselves. `buzz personas create|list|get|delete` writes the same kind:30175 coordinates Desktop reads, from a flag set or straight from a `.agent.json` export. These are owner-authored events, so the signing key IS the owner: no NIP-OA auth tag is involved, and running with a different key publishes to a coordinate space the owner's Desktop never reads. Publishing a definition does not start an agent; launching one mints key material and stays a Desktop operation. Two relay behaviors shape the implementation: - A write must be stamped past the coordinate's current head. NIP-33 breaks a `created_at` tie by lowest event id, so a same-second rewrite can otherwise lose to the event it was replacing. - `soft_delete_by_coordinate` matches `created_at <= tombstone`, and its result only feeds a debug log. A tombstone older than its target is accepted, deletes nothing, and reports OK — so delete stamps from the head it just read and then re-reads the coordinate to confirm. The re-read only raises a conflict for a head strictly newer than the tombstone, since a lagging replica can still return the deleted head. `--from` refuses rather than repairs a snapshot that would publish a persona Desktop can't mint from: `respondTo=allowlist` with no pubkeys, an unknown `respondTo`, or definition text carrying invisible characters. Avatars follow Desktop's reader rather than a CLI-only rule. `--avatar` takes a local image and carries it inline as a data URL when it fits the bounds Desktop renders — 8 KiB for SVG, 256 KiB for raster, both inside the relay's 256 KiB content cap. Anything larger is downscaled to 512px and re-encoded, then re-checked against the bound: flat art typically lands back inside it and never reaches media storage at all, and only what still doesn't fit is uploaded. Re-encoding is also what makes that upload work. Media storage refuses images carrying metadata — EXIF, colour profiles, comments — as an identity channel, so a photo straight off a camera failed outright. Decoding and re-encoding drops metadata by construction rather than by stripping known chunks, so it cannot drift from the relay's allowlist the way a structural stripper would. EXIF orientation is baked into the pixels first, because dropping the tag without applying it publishes the avatar sideways. GIF passes through untouched, since re-encoding would flatten animation, and WebP re-encodes to PNG because `image`'s WebP encoder is lossless-only and would inflate a lossy source. `--from` carries a snapshot's inlined avatar through that same path, so a Desktop export round-trips with its image. The upload runs after the `--replace` conflict check: an upload that a rejected write would strand leaves an orphan blob behind. Signed-off-by: Max Lampert <maxwell@squareup.com>
This commit is contained in:
Generated
+1
@@ -984,6 +984,7 @@ dependencies = [
|
||||
"diffy",
|
||||
"dirs",
|
||||
"hex",
|
||||
"image",
|
||||
"infer",
|
||||
"nostr 0.44.7",
|
||||
"rand 0.10.1",
|
||||
|
||||
@@ -63,6 +63,17 @@ bytes = "1"
|
||||
# MIME type detection via magic bytes — file upload validation
|
||||
infer = "0.19"
|
||||
|
||||
# Avatar normalization — decode/downscale/re-encode drops image metadata by
|
||||
# construction, which media storage requires. Same version and feature set as
|
||||
# buzz-media and desktop; depending on buzz-media instead would pull rust-s3,
|
||||
# axum, and mp4 into this binary.
|
||||
image = { version = "0.25", default-features = false, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"webp",
|
||||
] }
|
||||
|
||||
# URL parsing — extract server domain for Blossom auth tag
|
||||
url = { workspace = true }
|
||||
|
||||
|
||||
@@ -482,6 +482,107 @@ buzz notes get --name dco-check # exits non-zero: not found
|
||||
buzz notes rm --name does-not-exist # exits non-zero
|
||||
```
|
||||
|
||||
### 6.13 Personas (NIP-AP, kind:30175)
|
||||
|
||||
Owner-authored agent definitions. These are the same coordinates
|
||||
Buzz Desktop reads, so run them with the **same key as the Desktop you expect
|
||||
the definitions to appear in** — a different key writes to a different
|
||||
coordinate space and Desktop shows nothing.
|
||||
|
||||
```bash
|
||||
# create (flags)
|
||||
buzz personas create --display-name "Herring" --prompt "Ask the annoying question." \
|
||||
--runtime claude --model claude-opus-5 | jq .
|
||||
# → {event_id, accepted, message, slug} — slug is the d-tag ("herring")
|
||||
|
||||
# create (from a Desktop export; --replace required to overwrite)
|
||||
buzz personas create --from ~/Downloads/Herring.agent.json --replace | jq .
|
||||
|
||||
# avatars: --avatar takes a local image, --avatar-url takes a hosted URL.
|
||||
# Every raster is downscaled to 512px and re-encoded, then measured: it rides
|
||||
# inline in the event when it fits (so Desktop renders it without a fetch),
|
||||
# and uploads when it doesn't. Flat art usually lands back inside the bound.
|
||||
buzz personas create --display-name "Herring" --prompt x --avatar ~/Pictures/herring.png | jq .
|
||||
|
||||
# a photo straight off a camera — carries EXIF, which media storage refuses.
|
||||
# Re-encoding drops it, so this must succeed rather than fail with a 422.
|
||||
buzz personas create --display-name "Herring" --prompt x --avatar ~/Pictures/IMG_1234.jpg --replace | jq .
|
||||
# check the avatar is upright: a rotated result means EXIF orientation was
|
||||
# dropped instead of applied.
|
||||
|
||||
# a small photo rides inline, which skips media storage entirely — so confirm
|
||||
# the CLI, not the validator, is what dropped the EXIF:
|
||||
buzz personas get herring --json | jq -r '.[0].content' \
|
||||
| jq -r .avatar_url | sed 's/^data:[^,]*,//' | base64 -d | exiftool -
|
||||
# → no GPS, no Make/Model, no colour profile
|
||||
|
||||
# list / get (the slug is positional, matching `projects` and `mem`)
|
||||
buzz personas list
|
||||
buzz personas get herring
|
||||
buzz personas get herring --json | jq . # sig-stripped array of one
|
||||
|
||||
# delete (NIP-09 a-tag tombstone; the CLI re-reads to confirm the coordinate
|
||||
# is gone, because the relay accepts a tombstone that deleted nothing)
|
||||
buzz personas delete herring
|
||||
buzz personas get herring # exits non-zero: not found
|
||||
```
|
||||
|
||||
Checks worth making by hand:
|
||||
|
||||
```bash
|
||||
# Desktop rejects invisible characters in definition text; so must the CLI
|
||||
buzz personas create --display-name $'Reviewer' --prompt x; echo "exit: $?" # 1
|
||||
|
||||
# Re-creating without --replace is a write conflict, not a silent overwrite
|
||||
buzz personas create --display-name "Herring" --prompt y; echo "exit: $?" # 5
|
||||
|
||||
# --replace keeps catalog visibility: --shared survives a replace that omits it
|
||||
buzz personas create --display-name "Herring" --prompt x --shared --replace
|
||||
buzz personas create --display-name "Herring" --prompt y --replace
|
||||
buzz personas get herring | grep shared # → shared: true
|
||||
|
||||
# A non-image --avatar is refused locally rather than after a round trip
|
||||
buzz personas create --display-name "Herring" --prompt x --avatar ./notes.txt; echo "exit: $?" # 1
|
||||
|
||||
# An --avatar-url Desktop's reader drops is refused rather than published to
|
||||
# render as nothing
|
||||
buzz personas create --display-name "Herring" --prompt x \
|
||||
--avatar-url 'ftp://example.test/h.png'; echo "exit: $?" # 1
|
||||
|
||||
# Bounds Desktop enforces at mint, so the CLI must not publish past them
|
||||
buzz personas create --display-name "Herring" --prompt x --parallelism 99; echo "exit: $?"
|
||||
# → "99 is not in 1..=32", exit 1
|
||||
|
||||
# Re-importing a persona Desktop already published must not mint a second
|
||||
# coordinate. Desktop publishes in-app personas under their record UUID, so
|
||||
# the import adopts that id rather than the slug its name derives.
|
||||
buzz personas create --from ~/Downloads/Herring.agent.json; echo "exit: $?"
|
||||
# → note: 'Herring' is already published as '<uuid>'
|
||||
# → exit 5, "persona '<uuid>' already exists — pass --replace to overwrite it"
|
||||
buzz personas create --from ~/Downloads/Herring.agent.json --replace | jq -r .slug
|
||||
# → the UUID, not "herring". `buzz personas list` still shows one Herring.
|
||||
|
||||
# Only an identical definition adopts. Edit the prompt and it is a different
|
||||
# persona, published at its own slug rather than overwriting Desktop's.
|
||||
jq '.definition.systemPrompt = "Something else."' ~/Downloads/Herring.agent.json > /tmp/other.agent.json
|
||||
buzz personas create --from /tmp/other.agent.json | jq -r .slug
|
||||
# → note: a different persona named 'Herring' is published as '<uuid>'
|
||||
# → "herring"
|
||||
|
||||
# A snapshot import is fail-closed: a wrong-typed field is refused, not
|
||||
# dropped, so the persona published always matches the file
|
||||
jq '.definition.namePool = ["Herring", 42]' ~/Downloads/Herring.agent.json > /tmp/bad.agent.json
|
||||
buzz personas create --from /tmp/bad.agent.json; echo "exit: $?"
|
||||
# → "not a valid v1 agent snapshot", exit 1
|
||||
|
||||
# Past the 5 MiB Desktop itself refuses to import
|
||||
head -c 6000000 /dev/zero | tr '\0' a > /tmp/big.agent.json
|
||||
buzz personas create --from /tmp/big.agent.json; echo "exit: $?" # 1, "snapshot limit"
|
||||
```
|
||||
|
||||
Cross-check in Desktop: after `personas create`, the definition appears in the
|
||||
agent picker.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Path Testing
|
||||
@@ -621,3 +722,7 @@ buzz channels delete --channel "$FORUM_ID" | jq .
|
||||
| 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit |
|
||||
| 61 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound |
|
||||
| 62 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` → exit 1 |
|
||||
| 63 | `personas create` | ☐ | Flags, `--from` snapshot, `--replace` conflict → exit 5, invisible-character reject → exit 1 |
|
||||
| 63a | `personas create --avatar` | ☐ | Small image inlines with its metadata stripped; large flat art downscales back to inline; EXIF-bearing photo succeeds and lands upright; non-image → exit 1; `--avatar-url` conflict or non-http(s) URL → exit 1 |
|
||||
| 64 | `personas list` / `get` | ☐ | `--json` is a sig-stripped array |
|
||||
| 65 | `personas delete` | ☐ | Delete→get 404; warns when a published team still lists it |
|
||||
|
||||
@@ -1108,6 +1108,15 @@ impl BuzzClient {
|
||||
let bytes = std::fs::read(file_path)
|
||||
.map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?;
|
||||
|
||||
self.upload_bytes(bytes).await
|
||||
}
|
||||
|
||||
/// Upload an in-memory blob to the relay's Blossom endpoint.
|
||||
///
|
||||
/// The MIME type is detected from the bytes themselves, so callers holding
|
||||
/// decoded image data — an avatar carried inline in a snapshot, say — do
|
||||
/// not have to stage a temporary file to reuse the upload path.
|
||||
pub async fn upload_bytes(&self, bytes: Vec<u8>) -> Result<BlobDescriptor, CliError> {
|
||||
// 2. Detect MIME from magic bytes
|
||||
let mime = infer::get(&bytes)
|
||||
.map(|t| t.mime_type().to_string())
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Relay plumbing shared by `buzz personas` and `buzz teams`.
|
||||
//!
|
||||
//! Persona (kind:30175) and team (kind:30176) events are NIP-33
|
||||
//! parameterized-replaceable and authored by the OWNER. The CLI identity is
|
||||
//! therefore the owner itself — unlike `buzz agents draft-*`, which is an agent
|
||||
//! asking an owner to act, no NIP-OA auth tag is involved. Run these commands
|
||||
//! with the same key as the Buzz Desktop you expect the definitions to appear
|
||||
//! in; a different key publishes to a different coordinate space.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use buzz_sdk::agent_definitions::event_d_tag;
|
||||
use nostr::{EventBuilder, JsonUtil};
|
||||
|
||||
use crate::client::BuzzClient;
|
||||
use crate::error::CliError;
|
||||
|
||||
/// Maximum definitions fetched per listing query.
|
||||
pub const LIST_LIMIT: usize = 500;
|
||||
|
||||
/// Relay ceiling on event content, mirroring `MAX_EVENT_CONTENT_BYTES` in
|
||||
/// `buzz-relay`'s ingest path.
|
||||
pub const MAX_EVENT_CONTENT_LEN: usize = 256 * 1024;
|
||||
|
||||
/// Seconds since the Unix epoch, saturating at 0 on a pre-epoch clock.
|
||||
pub fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Parse a relay-response JSON array of events, discarding entries that fail to
|
||||
/// deserialize so one corrupt record can't deny-of-service a whole listing.
|
||||
///
|
||||
/// Returns the parsed events and how many the relay actually sent. Callers that
|
||||
/// test against a query limit must use the returned count, not the vector
|
||||
/// length, or a dropped record hides a real truncation.
|
||||
fn parse_events(json: &str) -> Result<(Vec<nostr::Event>, usize), CliError> {
|
||||
let value: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|e| CliError::Other(format!("relay returned invalid JSON: {e}")))?;
|
||||
let arr = value
|
||||
.as_array()
|
||||
.ok_or_else(|| CliError::Other("relay response is not an array".into()))?;
|
||||
let parsed = arr
|
||||
.iter()
|
||||
.filter_map(|ev| serde_json::from_value::<nostr::Event>(ev.clone()).ok())
|
||||
.collect();
|
||||
Ok((parsed, arr.len()))
|
||||
}
|
||||
|
||||
/// Fetch definitions of `kind` authored by the CLI identity, up to
|
||||
/// [`LIST_LIMIT`] events.
|
||||
///
|
||||
/// The relay retains one event per coordinate, but a response may still carry
|
||||
/// several events for one d-tag across reconnects, so this keeps the NIP-33
|
||||
/// winner per d-tag: greatest `created_at`, ties broken by lowest event id.
|
||||
///
|
||||
/// Not paginated. An identity at the cap gets a truncated listing, which would
|
||||
/// also make `teams create` report a published persona as missing, so a hit on
|
||||
/// the cap warns rather than silently truncating.
|
||||
pub async fn list_owned(client: &BuzzClient, kind: u32) -> Result<Vec<nostr::Event>, CliError> {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [kind],
|
||||
"authors": [client.keys().public_key().to_hex()],
|
||||
"limit": LIST_LIMIT,
|
||||
});
|
||||
let (events, returned) = parse_events(&client.query(&filter).await?)?;
|
||||
if returned >= LIST_LIMIT {
|
||||
eprintln!(
|
||||
"warning: hit the {LIST_LIMIT}-event listing cap for kind {kind}; \
|
||||
results may be incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
let mut heads: Vec<nostr::Event> = Vec::new();
|
||||
for event in events {
|
||||
let Some(d_tag) = event_d_tag(&event).map(str::to_owned) else {
|
||||
continue; // not addressable — cannot belong to any coordinate
|
||||
};
|
||||
match heads
|
||||
.iter()
|
||||
.position(|e| event_d_tag(e) == Some(d_tag.as_str()))
|
||||
{
|
||||
Some(i) if supersedes(&event, &heads[i]) => heads[i] = event,
|
||||
Some(_) => {}
|
||||
None => heads.push(event),
|
||||
}
|
||||
}
|
||||
heads.sort_by(|a, b| event_d_tag(a).cmp(&event_d_tag(b)));
|
||||
Ok(heads)
|
||||
}
|
||||
|
||||
/// NIP-33 head selection: greater `created_at` wins, ties broken by lower id.
|
||||
fn supersedes(candidate: &nostr::Event, incumbent: &nostr::Event) -> bool {
|
||||
match candidate.created_at.cmp(&incumbent.created_at) {
|
||||
std::cmp::Ordering::Greater => true,
|
||||
std::cmp::Ordering::Less => false,
|
||||
std::cmp::Ordering::Equal => candidate.id < incumbent.id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the retained head for one `(kind, owner, d_tag)` coordinate.
|
||||
///
|
||||
/// A head that exists but does not deserialize is an error, not `None`:
|
||||
/// callers read `None` as "coordinate is free" and would then stamp a write at
|
||||
/// a bare `now`, which can lose the NIP-33 tie-break to the head they could not
|
||||
/// read — or, for a delete, be silently discarded by the relay.
|
||||
pub async fn fetch_head(
|
||||
client: &BuzzClient,
|
||||
kind: u32,
|
||||
d_tag: &str,
|
||||
) -> Result<Option<nostr::Event>, CliError> {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [kind],
|
||||
"authors": [client.keys().public_key().to_hex()],
|
||||
"#d": [d_tag],
|
||||
"limit": 16,
|
||||
});
|
||||
let (events, returned) = parse_events(&client.query(&filter).await?)?;
|
||||
if events.is_empty() && returned > 0 {
|
||||
return Err(CliError::Other(format!(
|
||||
"relay holds {returned} unreadable event(s) at kind {kind} d-tag \
|
||||
'{d_tag}'; refusing to write over a head this CLI cannot parse"
|
||||
)));
|
||||
}
|
||||
Ok(events
|
||||
.into_iter()
|
||||
.fold(None, |head: Option<nostr::Event>, e| match head {
|
||||
Some(h) if !supersedes(&e, &h) => Some(h),
|
||||
_ => Some(e),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Stamp for a write at `now` over `prior_head`.
|
||||
///
|
||||
/// Passing `None` for a coordinate that does have a head is the silent failure
|
||||
/// this exists to make testable: the write lands at a bare `now`, which a head
|
||||
/// already bumped into the future outranks — and for a kind:5 tombstone the
|
||||
/// relay accepts the miss and reports OK.
|
||||
fn write_created_at(now: u64, prior_head: Option<&nostr::Event>) -> u64 {
|
||||
buzz_core::engram::monotonic_created_at(now, prior_head.map(|e| e.created_at.as_secs()))
|
||||
}
|
||||
|
||||
/// Sign `builder` at the NIP-AP monotonic `created_at` for this coordinate and
|
||||
/// submit it.
|
||||
///
|
||||
/// `prior_head` is the coordinate's current head, if any. NIP-33 keeps the
|
||||
/// greatest `created_at` and breaks ties by lowest event id, so a same-second
|
||||
/// rewrite can otherwise lose to the event it was meant to replace; the bump
|
||||
/// past the head makes the write win regardless of clock skew.
|
||||
///
|
||||
/// Returns the signed event and the relay's normalized write response, which
|
||||
/// callers emit on stdout per the buzz-cli write contract.
|
||||
pub async fn publish_definition(
|
||||
client: &BuzzClient,
|
||||
builder: EventBuilder,
|
||||
prior_head: Option<&nostr::Event>,
|
||||
) -> Result<(nostr::Event, serde_json::Value), CliError> {
|
||||
let created_at = write_created_at(now_secs(), prior_head);
|
||||
let event = builder
|
||||
.custom_created_at(nostr::Timestamp::from(created_at))
|
||||
.sign_with_keys(client.keys())
|
||||
.map_err(|e| CliError::Other(format!("failed to sign event: {e}")))?;
|
||||
|
||||
let raw = client.submit_event(event.clone()).await?;
|
||||
let normalized = super::parse_write_response(
|
||||
&raw,
|
||||
"relay reported the write as duplicate / dominated by a newer head",
|
||||
)?;
|
||||
let response = serde_json::from_str(&normalized)
|
||||
.map_err(|e| CliError::Other(format!("relay response is not JSON: {e}")))?;
|
||||
Ok((event, response))
|
||||
}
|
||||
|
||||
/// Emit a write response on stdout with the entity's own id folded in.
|
||||
///
|
||||
/// The buzz-cli contract is that writes return `{event_id, accepted, message}`
|
||||
/// and creates add the entity id, so agent callers can consume the result.
|
||||
pub fn print_write_response(mut response: serde_json::Value, key: &str, value: &str) {
|
||||
if let Some(obj) = response.as_object_mut() {
|
||||
obj.insert(
|
||||
key.to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
println!("{response}");
|
||||
}
|
||||
|
||||
/// Read a definition body from a file, or stdin when `path` is `-`, rejecting
|
||||
/// an empty one.
|
||||
///
|
||||
/// An empty prompt or instruction file almost always means an upstream step
|
||||
/// produced nothing, and publishing it would silently blank the definition.
|
||||
pub fn read_body_file(path: &str, what: &str) -> Result<String, CliError> {
|
||||
let body = crate::validate::read_file_or_stdin(path)?;
|
||||
if body.trim().is_empty() {
|
||||
return Err(CliError::Usage(format!(
|
||||
"{what} file '{path}' is empty — refusing to publish a blank {what}"
|
||||
)));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// The snapshot schema version this CLI knows how to read.
|
||||
///
|
||||
/// Matches Buzz Desktop's own snapshot validators, which reject anything else.
|
||||
pub const SNAPSHOT_VERSION: u64 = 1;
|
||||
|
||||
/// Largest agent snapshot this CLI reads, mirroring `MAX_SNAPSHOT_JSON_BYTES`
|
||||
/// in Buzz Desktop's persona import.
|
||||
pub const MAX_AGENT_SNAPSHOT_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Load and parse a Buzz Desktop snapshot export (`.agent.json` /
|
||||
/// `.team.json`), checking the `format` and `version` envelope before the caller
|
||||
/// reads its payload. Refuses a file past `max_bytes`.
|
||||
pub fn read_snapshot(
|
||||
path: &str,
|
||||
expected_format: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<serde_json::Value, CliError> {
|
||||
use std::io::Read;
|
||||
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| CliError::Usage(format!("cannot read '{path}': {e}")))?;
|
||||
// Read one byte past the cap rather than trusting metadata: the length a
|
||||
// fifo or a file growing mid-read reports need not be what arrives.
|
||||
let mut raw = String::new();
|
||||
file.take(max_bytes as u64 + 1)
|
||||
.read_to_string(&mut raw)
|
||||
.map_err(|e| CliError::Usage(format!("cannot read '{path}': {e}")))?;
|
||||
if raw.len() > max_bytes {
|
||||
return Err(CliError::Usage(format!(
|
||||
"'{path}' is larger than the {max_bytes}-byte snapshot limit, \
|
||||
which Buzz Desktop also refuses to import"
|
||||
)));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Usage(format!("'{path}' is not valid JSON: {e}")))?;
|
||||
let format = value.get("format").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if format != expected_format {
|
||||
return Err(CliError::Usage(format!(
|
||||
"'{path}' has format '{format}', expected '{expected_format}'"
|
||||
)));
|
||||
}
|
||||
// Field meanings are version-scoped, so reading a future export under v1
|
||||
// semantics would misinterpret it rather than fail.
|
||||
match value.get("version").and_then(serde_json::Value::as_u64) {
|
||||
Some(SNAPSHOT_VERSION) => {}
|
||||
Some(v) => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"'{path}' is snapshot version {v}, but this CLI reads version \
|
||||
{SNAPSHOT_VERSION} — upgrade buzz"
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"'{path}' has no numeric `version` field"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Print an event as a sig-stripped one-element JSON array.
|
||||
///
|
||||
/// The buzz-cli contract is that reads emit sig-stripped arrays, so a `get`
|
||||
/// stays parseable by the same consumer that reads a `list`.
|
||||
pub fn print_event_json(event: &nostr::Event) -> Result<(), CliError> {
|
||||
let value = serde_json::from_str(&event.as_json())
|
||||
.map_err(|e| CliError::Other(format!("failed to re-encode event: {e}")))?;
|
||||
println!("{}", crate::client::normalize_events(&[value]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{Keys, Kind, Tag};
|
||||
|
||||
fn event_at(d_tag: &str, created_at: u64) -> nostr::Event {
|
||||
EventBuilder::new(Kind::Custom(30175), "{}")
|
||||
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
|
||||
.custom_created_at(nostr::Timestamp::from(created_at))
|
||||
.sign_with_keys(&Keys::generate())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_created_at_supersedes() {
|
||||
let old = event_at("x", 100);
|
||||
let new = event_at("x", 200);
|
||||
assert!(supersedes(&new, &old));
|
||||
assert!(!supersedes(&old, &new));
|
||||
}
|
||||
|
||||
/// NIP-33 breaks a `created_at` tie by lowest event id, so a rewrite at the
|
||||
/// same second is not guaranteed to win — the reason writes bump past the
|
||||
/// head instead of relying on the tiebreak.
|
||||
#[test]
|
||||
fn created_at_tie_breaks_on_lower_id() {
|
||||
let (a, b) = (event_at("x", 100), event_at("x", 100));
|
||||
let (lower, higher) = if a.id < b.id { (a, b) } else { (b, a) };
|
||||
assert!(supersedes(&lower, &higher));
|
||||
assert!(!supersedes(&higher, &lower));
|
||||
}
|
||||
|
||||
/// A rewrite in the same second bumps the head past the wall clock, so a
|
||||
/// later write — including a delete — must be stamped from the head, not
|
||||
/// from `now`, or the relay keeps the event it was meant to replace.
|
||||
#[test]
|
||||
fn a_write_is_stamped_past_the_head_it_replaces() {
|
||||
assert_eq!(write_created_at(100, None), 100);
|
||||
assert_eq!(write_created_at(100, Some(&event_at("x", 100))), 101);
|
||||
assert_eq!(
|
||||
write_created_at(100, Some(&event_at("x", 200))),
|
||||
201,
|
||||
"a head ahead of the clock must still be outranked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_body_file_rejects_blank_input() {
|
||||
let dir = std::env::temp_dir().join(format!("buzz-defs-{}", now_secs()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("blank.md");
|
||||
std::fs::write(&path, " \n\t\n").unwrap();
|
||||
let err = read_body_file(path.to_str().unwrap(), "prompt").unwrap_err();
|
||||
assert!(err.to_string().contains("refusing to publish"), "{err}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_snapshot_rejects_the_wrong_envelope() {
|
||||
let dir = std::env::temp_dir().join(format!("buzz-defs-snap-{}", now_secs()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("wrong.json");
|
||||
std::fs::write(&path, r#"{"format":"buzz-team-snapshot","version":1}"#).unwrap();
|
||||
let err = read_snapshot(
|
||||
path.to_str().unwrap(),
|
||||
"buzz-agent-snapshot",
|
||||
MAX_AGENT_SNAPSHOT_BYTES,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("expected 'buzz-agent-snapshot'"),
|
||||
"{err}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod agents;
|
||||
pub mod channel_templates;
|
||||
pub mod channels;
|
||||
pub mod definitions;
|
||||
pub mod dms;
|
||||
pub mod emoji;
|
||||
pub mod feed;
|
||||
@@ -11,6 +12,7 @@ pub mod moderation;
|
||||
pub mod notes;
|
||||
pub mod pack;
|
||||
pub mod patches;
|
||||
pub mod personas;
|
||||
pub mod pr;
|
||||
pub mod projects;
|
||||
pub mod reactions;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -237,6 +237,9 @@ enum Cmd {
|
||||
/// Persona pack operations (local, no relay connection needed)
|
||||
#[command(subcommand)]
|
||||
Pack(PackCmd),
|
||||
/// Publish and manage agent definitions (personas) on the relay
|
||||
#[command(subcommand)]
|
||||
Personas(PersonasCmd),
|
||||
/// Community moderation — reports queue, bans, timeouts, audit trail
|
||||
#[command(subcommand)]
|
||||
Moderation(ModerationCmd),
|
||||
@@ -1858,6 +1861,99 @@ pub enum PackCmd {
|
||||
},
|
||||
}
|
||||
|
||||
/// Flags for `buzz personas create`.
|
||||
///
|
||||
/// A `clap::Args` struct rather than inline variant fields so the CLI surface
|
||||
/// and the resolver share one definition of the create inputs.
|
||||
#[derive(clap::Args)]
|
||||
pub struct PersonaCreateArgs {
|
||||
/// Persona slug (the event d-tag). Defaults to the display name,
|
||||
/// normalized to the relay's `[a-z0-9][a-z0-9_-]{0,63}` grammar
|
||||
#[arg(long)]
|
||||
pub slug: Option<String>,
|
||||
/// Human-readable name shown in clients
|
||||
#[arg(long)]
|
||||
pub display_name: Option<String>,
|
||||
/// System prompt text
|
||||
#[arg(long, conflicts_with = "prompt_file")]
|
||||
pub prompt: Option<String>,
|
||||
/// Read the system prompt from a file
|
||||
#[arg(long)]
|
||||
pub prompt_file: Option<String>,
|
||||
/// Agent harness to run under (e.g. claude, codex, buzz-agent)
|
||||
#[arg(long)]
|
||||
pub runtime: Option<String>,
|
||||
/// Model identifier, interpreted relative to the runtime
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
/// Inference provider, when the runtime supports more than one
|
||||
#[arg(long)]
|
||||
pub provider: Option<String>,
|
||||
/// Avatar image file. Carried in the event when small enough for Desktop to
|
||||
/// render inline, uploaded to media storage otherwise
|
||||
#[arg(long, conflicts_with = "avatar_url")]
|
||||
pub avatar: Option<String>,
|
||||
/// Avatar URL, used as-is. Use `--avatar` to publish a local image
|
||||
#[arg(long)]
|
||||
pub avatar_url: Option<String>,
|
||||
/// Who instances answer by default
|
||||
#[arg(long, value_enum)]
|
||||
pub respond_to: Option<RespondToArg>,
|
||||
/// Concurrent turn limit copied onto instances at creation (1-32)
|
||||
#[arg(long, value_parser = clap::value_parser!(u32).range(1..=32))]
|
||||
pub parallelism: Option<u32>,
|
||||
/// Mark the persona for catalog discovery by other members
|
||||
#[arg(long)]
|
||||
pub shared: bool,
|
||||
/// Read fields from a Buzz Desktop `.agent.json` export; individual flags
|
||||
/// override the file
|
||||
#[arg(long)]
|
||||
pub from: Option<String>,
|
||||
/// Overwrite an existing persona at this slug
|
||||
#[arg(long)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
/// Subcommands for `buzz personas` — kind:30175 agent definitions.
|
||||
///
|
||||
/// These events are owner-authored, so the signing key IS the owner: run them
|
||||
/// with the same key as the Buzz Desktop you expect the personas to appear in.
|
||||
/// Publishing a definition does not start an agent — launching one mints key
|
||||
/// material and a NIP-OA auth tag and stays a Desktop operation.
|
||||
// clap cannot derive `Args` through a `Box`, and a subcommand enum is built
|
||||
// once per process — boxing to even out variant sizes would trade an allocation
|
||||
// for nothing.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Subcommand)]
|
||||
pub enum PersonasCmd {
|
||||
/// Publish a persona definition
|
||||
#[command(after_help = "Examples:\n \
|
||||
buzz personas create --display-name Herring --prompt-file ./herring.md \\\n \
|
||||
--runtime buzz-agent --model databricks-kimi-3\n \
|
||||
buzz personas create --from ./herring.agent.json\n \
|
||||
buzz personas create --from ./herring.agent.json --model claude-opus-5[1m] --runtime claude")]
|
||||
Create(PersonaCreateArgs),
|
||||
/// List personas published by this identity
|
||||
List {
|
||||
/// Emit JSON instead of a table
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Show one persona
|
||||
Get {
|
||||
/// Persona slug
|
||||
slug: String,
|
||||
/// Emit the relay event as a sig-stripped JSON array
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Delete a persona (NIP-09 coordinate tombstone)
|
||||
Delete {
|
||||
/// Persona slug
|
||||
slug: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Community moderation commands.
|
||||
///
|
||||
/// The community (tenant) is selected by the relay host in `--relay` /
|
||||
@@ -2058,6 +2154,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
Cmd::Media(sub) => commands::upload::dispatch_media(sub, &client).await,
|
||||
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
|
||||
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
|
||||
Cmd::Personas(sub) => commands::personas::dispatch(sub, &client).await,
|
||||
Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Pack(_) => unreachable!("handled above"),
|
||||
}
|
||||
@@ -2143,6 +2240,24 @@ mod tests {
|
||||
assert!(Cli::try_parse_from(["buzz", "users", "set-status", "--clear"]).is_ok());
|
||||
}
|
||||
|
||||
/// Buzz Desktop's mint gate is 1..=32, and the flag path must refuse the
|
||||
/// same values `--from` does rather than publishing a persona that fails at
|
||||
/// launch. 33 is the first value past the ceiling; 4294967296 overflows u32.
|
||||
#[test]
|
||||
fn persona_parallelism_is_bounded_at_the_flag() {
|
||||
for value in ["0", "33", "4294967296"] {
|
||||
assert!(
|
||||
Cli::try_parse_from(["buzz", "personas", "create", "--parallelism", value])
|
||||
.is_err(),
|
||||
"--parallelism {value} must be refused"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
Cli::try_parse_from(["buzz", "personas", "create", "--parallelism", "32"]).is_ok(),
|
||||
"32 is the ceiling, not past it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_inventory_is_stable() {
|
||||
let expected_groups: Vec<&str> = vec![
|
||||
@@ -2160,6 +2275,7 @@ mod tests {
|
||||
"notes",
|
||||
"pack",
|
||||
"patches",
|
||||
"personas",
|
||||
"pr",
|
||||
"projects",
|
||||
"reactions",
|
||||
@@ -2253,6 +2369,10 @@ mod tests {
|
||||
"update"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
names(&cmd, "personas"),
|
||||
vec!["create", "delete", "get", "list"]
|
||||
);
|
||||
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
|
||||
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user