diff --git a/crates/buzz-sdk/src/agent_definitions.rs b/crates/buzz-sdk/src/agent_definitions.rs new file mode 100644 index 00000000000..bcdedf29378 --- /dev/null +++ b/crates/buzz-sdk/src/agent_definitions.rs @@ -0,0 +1,464 @@ +//! Persona (kind:30175) and team (kind:30176) definition wire shapes. +//! +//! NIP-AP parameterized-replaceable events keyed by `(pubkey, kind, d_tag)`. +//! This module owns the published content projections and the pure +//! build/parse/delete helpers, so every publisher — the desktop app and the +//! CLI — shares one definition of the wire contract. Mapping a client's own +//! record type onto a projection stays with that client. +//! +//! Two things here are load-bearing and easy to "clean up" into silent data +//! loss: [`PersonaEventContent`]'s field order, which pins content bytes and +//! therefore event ids, and [`TeamEventContent`]'s `Option` nesting, which +//! distinguishes "unknown, preserve local" from "explicitly emptied". Read +//! their doc comments before changing either. + +use nostr::{EventBuilder, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::SdkError; +use buzz_core::kind::{KIND_PERSONA, KIND_TEAM}; + +/// NIP-09 deletion kind. +const KIND_DELETE: u16 = 5; + +/// Maximum d-tag length in the NIP-AP slug grammar. +pub const MAX_D_TAG_LEN: usize = 64; + +/// Deserialize a field so that "absent" and "present but null" stay distinct. +/// +/// `Option>`: outer `None` = key absent, `Some(None)` = explicit +/// `null`, `Some(Some(v))` = set. Serde collapses absent and null together +/// without this. +pub fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(Some) +} + +/// The JSON body stored in a persona event's content field. +/// +/// Field order MUST match the NIP-AP reference vectors (`docs/nips/NIP-AP.md` +/// content body: `display_name, system_prompt, avatar_url, runtime, model, +/// provider, name_pool`). serde emits fields in declaration order, so this +/// order pins the exact content bytes and therefore the NIP-01 event id — a +/// reorder breaks cross-implementation interop. +/// +/// Explicit opt-IN projection: `skip_serializing_if` on every optional field +/// keeps serialized bytes stable against pre-revision publishers, which is what +/// makes content-hash drift comparison meaningful. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaEventContent { + /// Human-readable name shown in clients. + pub display_name: String, + /// Optional since the unified agent model (NIP-AP revision): a definition + /// can be pure configuration. Writers emit `Some` whenever the record has a + /// prompt (including the empty string) so pre-revision content bytes are + /// unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// Avatar URL, or a data URL small enough for a reader to render inline. + /// Desktop's inline bounds are 8 KiB for `image/svg+xml` and 256 KiB for + /// raster; past those, upload to media storage and publish the URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + /// Preferred agent harness id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + /// Model identifier, interpreted relative to `runtime`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Inference provider, when the runtime supports more than one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Candidate display names for instances created from this definition. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub name_pool: Vec, + /// Definition-level defaults copied onto instances at creation (NIP-AP + /// behavioral fields). Absent = defer to client defaults; + /// `skip_serializing_if` keeps pre-revision hashes stable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, + /// Pubkeys an instance answers when `respond_to` is allowlist-scoped. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, + /// Concurrent turn limit copied onto instances at creation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, +} + +/// The JSON body stored in a team event's content field. +/// +/// Explicit opt-IN projection of the public team fields. A team carries no +/// secrets, but the projection is still explicit so a future record field is +/// published only when deliberately added here. Fields describing one client's +/// install (source directory, symlink state, builtin flag, timestamps) are +/// intentionally absent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TeamEventContent { + /// Team name. + pub name: String, + /// Optional description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Runtime-layered instructions. `Option` is PERMANENT wire semantics (not + /// a transitional shim): absent = publisher predates always-publish (true + /// value unknown — reconcile must preserve local), `null` = explicitly + /// cleared, a string = set. New clients always publish this field (outer + /// always `Some`), using `null` for "no instructions" so that state + /// round-trips instead of being read back as "unknown". + #[serde( + default, + deserialize_with = "double_option", + skip_serializing_if = "Option::is_none" + )] + pub instructions: Option>, + /// Persona members, by d-tag. `Option` is PERMANENT wire semantics, not a + /// transitional shim: `None` = publisher predates always-publish (its true + /// membership is unknown — reconcile must preserve local), while + /// `Some(vec![])` = explicitly emptied. New clients always publish + /// `Some(...)`, even when empty. "Cleaning up" the `Option` reintroduces + /// the bug where an old client's event silently wipes team membership (see + /// the Sietch Tabr incident). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_ids: Option>, +} + +/// Normalize a raw slug to the NIP-AP grammar `^[a-z0-9][a-z0-9_-]{0,63}$` +/// (`docs/nips/NIP-AP.md:27`). +/// +/// The relay enforces this grammar, so an un-normalized slug like +/// `CodeReviewer` or `_ops` is signed locally but REJECTED — pending forever. +/// The builders here deliberately do NOT call this: a d-tag is a coordinate, +/// and rewriting it under a caller would silently move an existing definition +/// to a new address. Callers route both the outbound publish and the inbound +/// match key through this fn so the two cannot drift. +/// +/// - ASCII-lowercase every char. +/// - Map any char outside `[a-z0-9_-]` to `-`. +/// - If the first char is not `[a-z0-9]` (a leading `_`/`-`), prepend `a` +/// rather than trimming — trimming `_ops`→`ops` would collide with a real +/// `ops` slug, whereas the prefix keeps distinct inputs distinct. +/// - Truncate to [`MAX_D_TAG_LEN`] bytes. +/// +/// Deterministic but NOT globally injective: two slugs differing only in case +/// collapse to one d-tag. That is the correct NIP-33 behavior — same logical +/// definition, one coordinate. +pub fn normalize_d_tag(raw: &str) -> String { + let mut out: String = raw + .chars() + .map(|c| { + let c = c.to_ascii_lowercase(); + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' { + c + } else { + '-' + } + }) + .collect(); + if !out + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()) + { + out.insert(0, 'a'); + } + out.truncate(MAX_D_TAG_LEN); + out +} + +/// Build a kind:30175 persona event from an already-projected content body. +/// +/// `d_tag` is written verbatim, so callers must pass it through +/// [`normalize_d_tag`] first — the relay enforces the slug grammar on persona +/// d-tags and rejects anything else. (Team ids are the opposite; see +/// [`build_team_event`].) `shared` adds the `["shared", "true"]` tag that marks +/// a persona for catalog discovery. Returns an unsigned builder; the caller +/// signs and submits. +pub fn build_persona_event( + d_tag: &str, + content: &PersonaEventContent, + shared: bool, +) -> Result { + let body = serde_json::to_string(content) + .map_err(|e| SdkError::InvalidInput(format!("failed to serialize persona content: {e}")))?; + let mut tags = vec![d_tag_of(d_tag)?]; + if shared { + tags.push( + Tag::parse(["shared", "true"]) + .map_err(|e| SdkError::InvalidTag(format!("invalid shared tag: {e}")))?, + ); + } + Ok(EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), body).tags(tags)) +} + +/// Build a kind:30176 team event from an already-projected content body. +/// +/// `d_tag` is the team's stable id, used verbatim. Do NOT pass it through +/// [`normalize_d_tag`]: the relay only length-bounds team ids, and Buzz Desktop +/// writes raw UUIDs and ids like `builtin-team:welcome`, so normalizing would +/// address a different coordinate than the one Desktop published. Returns an +/// unsigned builder; the caller signs and submits. +pub fn build_team_event(d_tag: &str, content: &TeamEventContent) -> Result { + let body = serde_json::to_string(content) + .map_err(|e| SdkError::InvalidInput(format!("failed to serialize team content: {e}")))?; + Ok(EventBuilder::new(Kind::Custom(KIND_TEAM as u16), body).tags(vec![d_tag_of(d_tag)?])) +} + +/// Build a NIP-09 deletion targeting a persona's kind:30175 coordinate. +pub fn build_persona_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result { + build_coordinate_delete(KIND_PERSONA, d_tag, owner_pubkey_hex) +} + +/// Build a NIP-09 deletion targeting a team's kind:30176 coordinate. +pub fn build_team_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result { + build_coordinate_delete(KIND_TEAM, d_tag, owner_pubkey_hex) +} + +/// Build a kind:5 deletion carrying a single NIP-33 coordinate `a`-tag. +/// +/// Deliberately no `e`-tag: an `e`-tag routes the relay to the event-id +/// deletion path, which leaves the parameterized-replaceable coordinate live. +/// The coordinate delete removes the definition for every client and across +/// reboots. +fn build_coordinate_delete( + kind: u32, + d_tag: &str, + owner_pubkey_hex: &str, +) -> Result { + let coord = format!("{kind}:{owner_pubkey_hex}:{d_tag}"); + let tag = Tag::parse(["a", coord.as_str()]) + .map_err(|e| SdkError::InvalidTag(format!("invalid a-tag: {e}")))?; + Ok(EventBuilder::new(Kind::Custom(KIND_DELETE), "").tags(vec![tag])) +} + +/// Parse a kind:30175 event's content into its projection. +pub fn persona_content_from_event(event: &nostr::Event) -> Result { + serde_json::from_str(event.content.as_ref()) + .map_err(|e| SdkError::InvalidInput(format!("failed to parse persona event content: {e}"))) +} + +/// Parse a kind:30176 event's content into its projection. +/// +/// Returns the projection, NOT a client record: install-specific local fields +/// cannot be represented, so an inbound event can only overwrite the shared +/// fields. The caller patches them onto its local record, matching on the +/// d-tag. +pub fn team_content_from_event(event: &nostr::Event) -> Result { + serde_json::from_str(event.content.as_ref()) + .map_err(|e| SdkError::InvalidInput(format!("failed to parse team event content: {e}"))) +} + +/// Read an event's `d` tag value. +pub fn event_d_tag(event: &nostr::Event) -> Option<&str> { + event.tags.iter().find_map(|tag| { + let values = tag.as_slice(); + match (values.first().map(String::as_str), values.get(1)) { + (Some("d"), Some(value)) => Some(value.as_str()), + _ => None, + } + }) +} + +fn d_tag_of(d_tag: &str) -> Result { + Tag::parse(["d", d_tag]).map_err(|e| SdkError::InvalidTag(format!("invalid d-tag: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + + const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + fn persona() -> PersonaEventContent { + PersonaEventContent { + display_name: "Herring".into(), + system_prompt: Some("You own output quality.".into()), + avatar_url: None, + runtime: Some("buzz-agent".into()), + model: Some("databricks-kimi-3".into()), + provider: None, + name_pool: vec!["Herring".into()], + respond_to: Some("owner-only".into()), + respond_to_allowlist: Vec::new(), + parallelism: Some(24), + } + } + + fn team() -> TeamEventContent { + TeamEventContent { + name: "Red team".into(), + description: Some("Three skeptics.".into()), + instructions: Some(None), + persona_ids: Some(vec!["quinby".into(), "herring".into()]), + } + } + + fn sign(builder: EventBuilder) -> nostr::Event { + builder.sign_with_keys(&Keys::generate()).unwrap() + } + + #[test] + fn persona_event_carries_kind_d_tag_and_content() { + let event = sign(build_persona_event("herring", &persona(), false).unwrap()); + assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA); + assert_eq!(event_d_tag(&event), Some("herring")); + assert_eq!(persona_content_from_event(&event).unwrap(), persona()); + } + + /// A d-tag is a coordinate: normalizing inside the builder would move an + /// existing definition to a new address behind the caller's back. + #[test] + fn builders_use_the_d_tag_verbatim() { + let persona_event = sign(build_persona_event("CodeReviewer", &persona(), false).unwrap()); + assert_eq!(event_d_tag(&persona_event), Some("CodeReviewer")); + let team_event = sign(build_team_event("Sietch-Tabr", &team()).unwrap()); + assert_eq!(event_d_tag(&team_event), Some("Sietch-Tabr")); + } + + #[test] + fn shared_flag_controls_the_shared_tag() { + for shared in [false, true] { + let event = sign(build_persona_event("herring", &persona(), shared).unwrap()); + let has = event + .tags + .iter() + .any(|t| t.as_slice().first().map(String::as_str) == Some("shared")); + assert_eq!(has, shared, "shared={shared}"); + } + } + + /// Field order pins the content bytes and therefore the event id; a reorder + /// breaks cross-implementation interop and every stored content hash. + #[test] + fn persona_content_field_order_matches_nip_ap() { + // Byte-exact, every field populated: a substring-order check would miss + // a field appearing in the wrong place among the ones it doesn't name. + let full = PersonaEventContent { + display_name: "Herring".into(), + system_prompt: Some("Ask the annoying question.".into()), + avatar_url: Some("https://example.invalid/h.png".into()), + runtime: Some("claude".into()), + model: Some("claude-opus-5".into()), + provider: Some("anthropic".into()), + name_pool: vec!["Herring".into()], + respond_to: Some("allowlist".into()), + respond_to_allowlist: vec!["ab".repeat(32)], + parallelism: Some(2), + }; + let allowlisted = "ab".repeat(32); + let expected = concat!( + r#"{"display_name":"Herring","system_prompt":"Ask the annoying question.","#, + r#""avatar_url":"https://example.invalid/h.png","runtime":"claude","#, + r#""model":"claude-opus-5","provider":"anthropic","name_pool":["Herring"],"#, + r#""respond_to":"allowlist","respond_to_allowlist":["{ALLOWLISTED}"],"parallelism":2}"#, + ) + .replace("{ALLOWLISTED}", &allowlisted); + assert_eq!(serde_json::to_string(&full).unwrap(), expected); + + // Absent optionals stay absent so pre-revision bytes are unchanged. + let json = serde_json::to_string(&persona()).unwrap(); + assert!(!json.contains("avatar_url"), "{json}"); + assert!(!json.contains("provider"), "{json}"); + assert!(!json.contains("respond_to_allowlist"), "{json}"); + } + + #[test] + fn team_event_round_trips_through_content() { + let event = sign(build_team_event("red-team", &team()).unwrap()); + assert_eq!(event.kind.as_u16() as u32, KIND_TEAM); + assert_eq!(event_d_tag(&event), Some("red-team")); + assert_eq!(team_content_from_event(&event).unwrap(), team()); + } + + /// Absent vs explicitly-null must stay distinguishable: absent means + /// "publisher predates always-publish, preserve local", null means + /// "cleared". Collapsing them wipes team state. + #[test] + fn absent_and_null_instructions_are_distinct() { + let absent: TeamEventContent = + serde_json::from_str(r#"{"name":"T","persona_ids":[]}"#).unwrap(); + assert_eq!(absent.instructions, None); + + let cleared: TeamEventContent = + serde_json::from_str(r#"{"name":"T","instructions":null,"persona_ids":[]}"#).unwrap(); + assert_eq!(cleared.instructions, Some(None)); + + let set: TeamEventContent = + serde_json::from_str(r#"{"name":"T","instructions":"go","persona_ids":[]}"#).unwrap(); + assert_eq!(set.instructions, Some(Some("go".into()))); + + // A cleared value must serialize as null, not vanish. + let json = serde_json::to_string(&cleared).unwrap(); + assert!(json.contains(r#""instructions":null"#), "{json}"); + } + + /// Absent membership means "unknown, preserve local"; an empty array means + /// "deliberately emptied". These must not serialize identically — the + /// absent shape is the Sietch Tabr wipe event. + #[test] + fn absent_and_empty_persona_ids_are_distinct() { + let absent: TeamEventContent = serde_json::from_str(r#"{"name":"T"}"#).unwrap(); + assert_eq!(absent.persona_ids, None); + assert!(!serde_json::to_string(&absent) + .unwrap() + .contains("persona_ids")); + + let emptied = TeamEventContent { + persona_ids: Some(Vec::new()), + ..absent + }; + assert!(serde_json::to_string(&emptied) + .unwrap() + .contains(r#""persona_ids":[]"#)); + } + + #[test] + fn d_tag_normalization_follows_the_relay_grammar() { + assert_eq!(normalize_d_tag("CodeReviewer"), "codereviewer"); + assert_eq!(normalize_d_tag("_ops"), "a_ops", "leading _ is prefixed"); + assert_eq!(normalize_d_tag("-x"), "a-x", "leading - is prefixed"); + assert_eq!(normalize_d_tag("a b.c"), "a-b-c"); + assert_eq!(normalize_d_tag(&"z".repeat(80)).len(), MAX_D_TAG_LEN); + assert_eq!(normalize_d_tag(""), "a", "empty stays grammar-valid"); + // Already-valid slugs pass through untouched. + assert_eq!(normalize_d_tag("red-team"), "red-team"); + } + + #[test] + fn delete_uses_one_coordinate_tag_and_no_e_tag() { + for (builder, kind, d_tag) in [ + ( + build_persona_delete("herring", OWNER).unwrap(), + KIND_PERSONA, + "herring", + ), + ( + build_team_delete("red-team", OWNER).unwrap(), + KIND_TEAM, + "red-team", + ), + ] { + let event = sign(builder); + assert_eq!(event.kind, Kind::Custom(KIND_DELETE)); + + let a_tags: Vec<&[String]> = event + .tags + .iter() + .map(|t| t.as_slice()) + .filter(|v| v.first().map(String::as_str) == Some("a")) + .collect(); + assert_eq!(a_tags.len(), 1); + assert_eq!(a_tags[0][1], format!("{kind}:{OWNER}:{d_tag}")); + + // An e-tag would route to the event-id deletion path and leave the + // replaceable coordinate live. + assert!(event + .tags + .iter() + .all(|t| t.as_slice().first().map(String::as_str) != Some("e"))); + } + } +} diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c882..0737b0c8a1a 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -12,6 +12,7 @@ //! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`. //! No keys are held here. No network calls are made. +pub mod agent_definitions; pub mod builders; pub mod mentions; pub mod nip_oa; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 7a3ce35b036..6f71cb5b86d 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -5,50 +5,18 @@ use std::collections::BTreeMap; -use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA}; -use nostr::{EventBuilder, Kind, Tag}; -use serde::{Deserialize, Serialize}; +use buzz_core_pkg::kind::event_is_shared; +use buzz_sdk_pkg::agent_definitions::normalize_d_tag; +use nostr::EventBuilder; use super::{AgentDefinition, ManagedAgentRecord}; use crate::app_state::AppState; -/// The JSON body stored in a persona event's content field. -/// -/// Field order MUST match the NIP-AP reference vectors (`docs/nips/NIP-AP.md` -/// content body: `display_name, system_prompt, avatar_url, runtime, model, -/// provider, name_pool`). serde emits fields in declaration order, so this -/// order pins the exact content bytes and therefore the NIP-01 event id — a -/// reorder here breaks cross-implementation interop. Guarded by +/// The published persona wire shape, owned by the SDK so the CLI publishes the +/// identical body — including the field order that pins content bytes and +/// therefore `persona_content_hash`. Guarded by /// `content_matches_nip_ap_vector`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PersonaEventContent { - pub display_name: String, - /// Optional since the unified agent model (NIP-AP revision): a definition - /// can be pure configuration. Writers emit `Some` whenever the record has - /// a prompt (including the empty string) so pre-revision content bytes — - /// and therefore `persona_content_hash` — are unchanged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub system_prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub avatar_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub runtime: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub name_pool: Vec, - /// Definition-level defaults copied onto instances at creation - /// (NIP-AP behavioral fields). Absent = defer to client defaults; - /// `skip_serializing_if` keeps pre-revision hashes stable. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub respond_to: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub respond_to_allowlist: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parallelism: Option, -} +pub use buzz_sdk_pkg::agent_definitions::PersonaEventContent; /// Derive the d-tag (persona slug) from a `AgentDefinition`. /// @@ -70,45 +38,6 @@ pub fn persona_d_tag(record: &AgentDefinition) -> String { normalize_d_tag(raw) } -/// Normalize a raw slug to the NIP-AP grammar `^[a-z0-9][a-z0-9_-]{0,63}$`. -/// -/// - ASCII-lowercase every char (pack slugs are `[a-zA-Z0-9_-]+`, so this is -/// the only transform uppercase slugs need). -/// - Map any char outside `[a-z0-9_-]` to `-` (defensive; pack slugs never -/// contain such chars, but `id` fallbacks and future inputs might). -/// - If the first char is not `[a-z0-9]` (i.e. a leading `_`/`-`), prepend `a` -/// rather than trimming — trimming `_ops`→`ops` would collide with a real -/// `ops` pack, whereas the prefix keeps distinct inputs distinct. -/// - Truncate to 64 bytes (the grammar's max). -/// -/// The transform is deterministic. It is NOT globally injective (`A-b` and -/// `a_b` both contain only safe chars and stay distinct, but two slugs -/// differing only in case — e.g. `Ops` and `ops` — collapse to the same -/// d-tag). That case-fold collision is inherent to the lowercase relay grammar -/// and is the correct NIP-33 behavior: same logical persona, one coordinate. -fn normalize_d_tag(raw: &str) -> String { - let mut out: String = raw - .chars() - .map(|c| { - let c = c.to_ascii_lowercase(); - if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' { - c - } else { - '-' - } - }) - .collect(); - if !out - .chars() - .next() - .is_some_and(|c| c.is_ascii_alphanumeric()) - { - out.insert(0, 'a'); - } - out.truncate(64); - out -} - /// Compute the NIP-AP monotonic `created_at` for a write (`docs/nips/NIP-AP.md:117` /// step 3): `max(now, T + 1)` where `T` is the retained head's `created_at` /// (or 0 when no head exists). @@ -121,9 +50,12 @@ fn normalize_d_tag(raw: &str) -> String { /// Bumping past the head guarantees a fresh write always supersedes regardless /// of clock skew. pub fn monotonic_created_at(prior_head_created_at: Option) -> nostr::Timestamp { - let now = nostr::Timestamp::now().as_secs() as i64; - let floor = prior_head_created_at.map_or(0, |t| t + 1); - nostr::Timestamp::from(now.max(floor) as u64) + // Arithmetic lives in buzz-core (pure, `now` injected, unit-tested there); + // this wrapper only supplies the clock. + nostr::Timestamp::from(buzz_core_pkg::engram::monotonic_created_at( + nostr::Timestamp::now().as_secs(), + prior_head_created_at.map(|t| t.max(0) as u64), + )) } /// Build a kind:30175 event from a `AgentDefinition`. @@ -132,19 +64,12 @@ pub fn monotonic_created_at(prior_head_created_at: Option) -> nostr::Timest pub fn build_persona_event(record: &AgentDefinition) -> Result { // Single projection point — persona_event_content owns the field mapping // (and the hash-stability rules that come with it). - let content = persona_event_content(record); - - let content_json = serde_json::to_string(&content) - .map_err(|e| format!("failed to serialize persona content: {e}"))?; - - let d_tag = persona_d_tag(record); - let mut tags = - vec![Tag::parse(["d", d_tag.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?]; - if record.shared { - tags.push(Tag::parse(["shared", "true"]).map_err(|e| format!("invalid shared tag: {e}"))?); - } - - Ok(EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), content_json).tags(tags)) + buzz_sdk_pkg::agent_definitions::build_persona_event( + &persona_d_tag(record), + &persona_event_content(record), + record.shared, + ) + .map_err(|e| e.to_string()) } /// Build a NIP-09 deletion (kind:5) targeting a persona's kind:30175 event. @@ -154,30 +79,20 @@ pub fn build_persona_event(record: &AgentDefinition) -> Result Result { - let coord = format!("{KIND_PERSONA}:{owner_pubkey_hex}:{d_tag}"); - let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?; - Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag])) + buzz_sdk_pkg::agent_definitions::build_persona_delete(d_tag, owner_pubkey_hex) + .map_err(|e| e.to_string()) } /// Parse a kind:30175 event back into a `AgentDefinition`. /// /// The event's d-tag becomes the persona ID and slug. pub fn persona_from_event(event: &nostr::Event) -> Result { - let d_tag = event - .tags - .iter() - .find_map(|tag| { - let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); - if values.first() == Some(&"d") { - values.get(1).map(|s| s.to_string()) - } else { - None - } - }) - .ok_or("persona event missing d-tag")?; + let d_tag = buzz_sdk_pkg::agent_definitions::event_d_tag(event) + .ok_or("persona event missing d-tag")? + .to_string(); - let content: PersonaEventContent = serde_json::from_str(event.content.as_ref()) - .map_err(|e| format!("failed to parse persona event content: {e}"))?; + let content = buzz_sdk_pkg::agent_definitions::persona_content_from_event(event) + .map_err(|e| e.to_string())?; let created_at = event.created_at.to_human_datetime(); diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 682fbef62fa..eb53f64833c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -1,5 +1,7 @@ use super::*; use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; +use buzz_core_pkg::kind::KIND_PERSONA; +use nostr::{Kind, Tag}; /// A linked instance record with no persona-derived fields set yet — the /// state right after creation, before any snapshot apply. diff --git a/desktop/src-tauri/src/managed_agents/team_events.rs b/desktop/src-tauri/src/managed_agents/team_events.rs index 64861c0dec6..f1d59962418 100644 --- a/desktop/src-tauri/src/managed_agents/team_events.rs +++ b/desktop/src-tauri/src/managed_agents/team_events.rs @@ -6,46 +6,14 @@ //! flush loop publish them — this module only owns the kind-specific //! projection, build, and tombstone. -use buzz_core_pkg::kind::KIND_TEAM; -use nostr::{EventBuilder, Kind, Tag}; -use serde::{Deserialize, Serialize}; +use nostr::EventBuilder; use super::TeamRecord; -/// The JSON body stored in a team event's content field. -/// -/// Explicit opt-IN projection of the public team fields. A team carries no -/// secrets, but the projection is still explicit so a future `TeamRecord` -/// field is published only when deliberately added here. Local-only fields -/// (`source_dir`, `is_symlink`, `is_builtin`, timestamps) are intentionally -/// omitted — they describe this client's install, not the shared team. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TeamEventContent { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Runtime-layered instructions. `Option` is PERMANENT wire semantics - /// (not a transitional shim): absent = publisher predates always-publish - /// (true value unknown — reconcile must preserve local), `null` = - /// explicitly cleared, a string = set. New clients always publish this - /// field (outer always `Some`), using `null` for "no instructions" so - /// that state round-trips instead of being read back as "unknown". - #[serde( - default, - deserialize_with = "crate::util::double_option", - skip_serializing_if = "Option::is_none" - )] - pub instructions: Option>, - /// Pack persona members. `Option` is PERMANENT wire semantics, not a - /// transitional shim: `None` = publisher predates always-publish (its - /// true membership is unknown — reconcile must preserve local), while - /// `Some(vec![])` = explicitly emptied. New clients always publish - /// `Some(...)`, even when empty. "Cleaning up" the Option later - /// reintroduces the bug where an old client's event silently wipes team - /// membership (see the Sietch Tabr incident). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub persona_ids: Option>, -} +/// The published team wire shape, owned by the SDK so the CLI publishes the +/// identical body — including the `Option` nesting that separates "publisher +/// predates always-publish, preserve local" from "explicitly emptied". +pub use buzz_sdk_pkg::agent_definitions::TeamEventContent; /// Project a `TeamRecord` onto the content fields published in team events. /// Centralizes the field mapping so a new published field is added in exactly @@ -66,11 +34,8 @@ pub fn team_event_content(record: &TeamRecord) -> TeamEventContent { /// /// Returns an unsigned `EventBuilder` — the caller signs and submits. pub fn build_team_event(record: &TeamRecord) -> Result { - let content = serde_json::to_string(&team_event_content(record)) - .map_err(|e| format!("failed to serialize team content: {e}"))?; - let tags = - vec![Tag::parse(["d", record.id.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?]; - Ok(EventBuilder::new(Kind::Custom(KIND_TEAM as u16), content).tags(tags)) + buzz_sdk_pkg::agent_definitions::build_team_event(&record.id, &team_event_content(record)) + .map_err(|e| e.to_string()) } /// Parse a kind:30176 event's content into the projection — the inbound @@ -83,8 +48,7 @@ pub fn build_team_event(record: &TeamRecord) -> Result { /// patches them onto the local record (see `apply_inbound_team`), matching on /// the d-tag (the team's id). pub fn team_content_from_event(event: &nostr::Event) -> Result { - serde_json::from_str(event.content.as_ref()) - .map_err(|e| format!("failed to parse team event content: {e}")) + buzz_sdk_pkg::agent_definitions::team_content_from_event(event).map_err(|e| e.to_string()) } /// Build a NIP-09 deletion (kind:5) targeting a team's kind:30176 event. @@ -94,14 +58,15 @@ pub fn team_content_from_event(event: &nostr::Event) -> Result Result { - let coord = format!("{KIND_TEAM}:{owner_pubkey_hex}:{d_tag}"); - let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?; - Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag])) + buzz_sdk_pkg::agent_definitions::build_team_delete(d_tag, owner_pubkey_hex) + .map_err(|e| e.to_string()) } #[cfg(test)] mod tests { use super::*; + use buzz_core_pkg::kind::KIND_TEAM; + use nostr::Kind; use std::path::PathBuf; fn sample_team() -> TeamRecord { diff --git a/desktop/src-tauri/src/util.rs b/desktop/src-tauri/src/util.rs index 7fa4dc6768e..0c2e72e265a 100644 --- a/desktop/src-tauri/src/util.rs +++ b/desktop/src-tauri/src/util.rs @@ -11,13 +11,10 @@ pub fn now_iso() -> String { /// "clear this field" indistinguishable from "leave it unchanged". Paired with /// `#[serde(default)]`, this yields the tri-state needed for nullable patches: /// absent → `None`, `null` → `Some(None)`, value → `Some(Some(value))`. -pub fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> -where - T: serde::Deserialize<'de>, - D: serde::Deserializer<'de>, -{ - serde::Deserialize::deserialize(deserializer).map(Some) -} +/// +/// Re-exported from the SDK: the persona/team wire shapes live there and depend +/// on this exact tri-state, so both sides must share one implementation. +pub use buzz_sdk_pkg::agent_definitions::double_option; /// Turn a human-readable name into a filesystem-safe slug. ///