feat(cli): mirror Desktop mention delivery (#3330)

🤖
## Summary

Agent-authored mentions currently depend on matching visible `@Name`
text to channel profiles. That makes notification delivery ambiguous
when names collide or profiles change, and it encourages an extra
post-send lookup just to confirm that the intended `p` tags were
emitted.

This change makes `buzz messages send` mirror Desktop's existing model:
the message keeps a readable name in its content while the recipient
pubkey is supplied separately.

```bash
buzz messages send \
  --channel <UUID> \
  --content '@Alice could you review this?' \
  --mention <alice-hex-or-npub>
```

`--mention` is repeatable. The CLI normalizes and deduplicates explicit
pubkeys, merges them with any names it can resolve from the channel, and
gives explicit identities priority under the existing 50-mention limit.

Before uploading attachments, signing, or publishing, the command checks
every resulting pubkey against the channel's current membership:

- Members are mentioned normally.
- Non-members stop the send and produce an actionable error.
- `--allow-non-member-mentions` deliberately sends notifying `p` tags
without adding anyone to the channel.

Sending a message never changes membership. On success,
`mention_pubkeys` is read from the exact signed event and returned with
the relay response, so callers can verify the emitted recipients without
another query.

Managed-agent guidance teaches this single-command mention flow. Desktop
mention behavior and the Nostr event schema are unchanged. Forum
guidance is intentionally handled separately in #3596.

### Related issue

None found. This replaces the earlier guidance-only approach in this PR
with the underlying CLI behavior it required.

### Testing

- `cargo test -p buzz-sdk`
- `cargo test -p buzz-cli`
- `cargo test -p buzz-acp`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml`

---------

Signed-off-by: npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz <4b78193083ca25112966f20d4368247d44e3965ba7219497e4cf6a98b308b9c0@buzz.block.builderlab.xyz>
Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz <4b78193083ca25112966f20d4368247d44e3965ba7219497e4cf6a98b308b9c0@buzz.block.builderlab.xyz>
Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-07-29 16:37:53 -04:00
committed by GitHub
co-authored by npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
parent 66e7054928
commit 7adc46268d
7 changed files with 288 additions and 50 deletions
+2
View File
@@ -40,6 +40,8 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat
- Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently.
- Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery.
- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention <hex-or-npub>`. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed.
- Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically.
- Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm.
### Callback Mentions
+16
View File
@@ -3625,6 +3625,22 @@ mod agent_draft_prompt_tests {
assert!(prompt.contains("single-quoted shell strings preserve `\\n` literally"));
assert!(prompt.contains("buzz messages send ... --content -"));
}
#[test]
fn shared_base_prompt_teaches_single_command_mentions_and_preflight() {
let prompt = include_str!("base_prompt.md");
assert!(prompt.contains("--mention <hex-or-npub>"));
assert!(prompt.contains("every presentation-only name that should notify"));
assert!(
prompt.contains("permits unresolved or ambiguous `@Name` text as presentation-only")
);
assert!(prompt.contains("success JSON's `mention_pubkeys`"));
assert!(prompt.contains("no follow-up verification command is needed"));
assert!(prompt.contains("stops before sending"));
assert!(prompt
.contains("add them explicitly with `buzz channels add-member` only when authorized"));
assert!(prompt.contains("never changes membership automatically"));
}
}
fn default_heartbeat_prompt() -> String {
+251 -43
View File
@@ -9,8 +9,7 @@ use crate::validate::{
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions,
MENTION_CAP,
extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP,
};
/// Extract the thread root event ID from a Nostr tag array.
@@ -119,47 +118,82 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result<Uuid,
)))
}
/// Resolve `@name` mentions in `content` against this channel's members.
fn resolve_names_to_pubkeys(
names: &[String],
name_to_pubkeys: &std::collections::HashMap<String, Vec<String>>,
has_explicit_mentions: bool,
) -> Result<Vec<String>, CliError> {
let mut resolved = Vec::new();
for name in names {
match name_to_pubkeys
.get(name)
.map(Vec::as_slice)
.unwrap_or_default()
{
[pubkey] => resolved.push(pubkey.clone()),
[] if has_explicit_mentions => {}
[] => {
return Err(CliError::Usage(format!(
"mention '@{name}' does not match a current channel member; retry with --mention <pubkey>"
)))
}
_ if has_explicit_mentions => {}
candidates => {
return Err(CliError::Usage(format!(
"mention '@{name}' is ambiguous; candidates: {}. Retry with --mention <pubkey>",
candidates.join(", ")
)))
}
}
}
Ok(resolved)
}
/// Resolve mention text against the channel membership snapshot.
///
/// Queries kind 39002 (channel members) then kind 0 (profiles), parses
/// display names once, and feeds them to [`extract_at_mentions_with_known`]
/// for multi-word matching. On any I/O or parse failure, returns an empty
/// vec — auto-tagging is best-effort and must never block a send.
/// Returns both the current member set and uniquely name-resolved pubkeys.
/// Lookup failures are fatal when mention processing is requested: publishing
/// visible mention text without its intended `p` tag is worse than not sending.
async fn resolve_content_mentions(
client: &BuzzClient,
channel_id: &str,
content: &str,
) -> Vec<String> {
if !content.contains('@') {
return vec![];
has_explicit_mentions: bool,
) -> Result<(Vec<String>, Vec<String>), CliError> {
let stripped = strip_code_regions(content);
if !stripped.contains('@') && !has_explicit_mentions {
return Ok((vec![], vec![]));
}
// 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag).
let members_filter = serde_json::json!({
"kinds": [39002],
"#d": [channel_id],
"limit": 1,
});
let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await {
Some(pks) if !pks.is_empty() => pks,
_ => return vec![],
};
let member_pubkeys = fetch_member_pubkeys(client, &members_filter)
.await
.ok_or_else(|| {
CliError::Other("could not load channel membership for mention preflight".into())
})?;
if !stripped.contains('@') {
return Ok((member_pubkeys, vec![]));
}
// 2. Profiles for those members (kind 0).
let profiles_filter = serde_json::json!({
"kinds": [0],
"authors": member_pubkeys,
"limit": member_pubkeys.len(),
});
let profile_events = match fetch_events(client, &profiles_filter).await {
Some(v) => v,
None => return vec![],
};
let profile_events = fetch_events(client, &profiles_filter)
.await
.ok_or_else(|| {
CliError::Other("could not load member profiles for mention resolution".into())
})?;
// 3. Single parse: extract (pubkey, display_name) pairs from profile JSON.
let mut name_to_pubkeys: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
let mut display_names: Vec<String> = Vec::new();
let mut display_names = Vec::new();
for e in &profile_events {
let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else {
continue;
@@ -178,26 +212,82 @@ async fn resolve_content_mentions(
else {
continue;
};
let lower = name.to_ascii_lowercase();
name_to_pubkeys
.entry(lower)
.entry(name.to_ascii_lowercase())
.or_default()
.push(pubkey.to_string());
display_names.push(name.to_string());
}
// 4. Two-pass extraction: known multi-word names first, single-word fallback.
let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect();
let names = extract_at_mentions_with_known(content, &known_refs);
let known_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
let names = extract_at_mentions_with_known(&stripped, &known_refs);
let resolved = resolve_names_to_pubkeys(&names, &name_to_pubkeys, has_explicit_mentions)?;
Ok((member_pubkeys, resolved))
}
// 5. Look up matched names → pubkeys via the map we already built.
names
fn normalize_explicit_mentions(values: &[String]) -> Result<Vec<String>, CliError> {
let mut normalized = Vec::new();
for value in values {
let pubkey = PublicKey::parse(value.trim())
.map_err(|_| CliError::Usage(format!("invalid --mention pubkey: {value}")))?;
let hex = pubkey.to_hex();
if !normalized.contains(&hex) {
normalized.push(hex);
}
}
if normalized.len() > MENTION_CAP {
return Err(CliError::Usage(format!(
"too many --mention values (max {MENTION_CAP})"
)));
}
Ok(normalized)
}
fn merge_message_mentions(
explicit: &[String],
uri_pubkeys: &[String],
auto_resolved: &[String],
) -> Result<Vec<String>, CliError> {
let mut mentions = Vec::new();
for pubkey in explicit
.iter()
.flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten())
.chain(uri_pubkeys.iter())
.chain(auto_resolved.iter())
{
if !mentions.contains(pubkey) {
mentions.push(pubkey.clone());
}
}
if mentions.len() > MENTION_CAP {
return Err(CliError::Usage(format!(
"too many unique message mentions (max {MENTION_CAP})"
)));
}
Ok(mentions)
}
fn missing_members(mentions: &[String], members: &[String]) -> Vec<String> {
let members: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect();
mentions
.iter()
.filter(|pk| !members.contains(pk.as_str()))
.cloned()
.collect()
}
fn event_mention_pubkeys(event: &nostr::Event) -> Vec<String> {
event
.tags
.iter()
.filter_map(|tag| {
let parts = tag.as_slice();
(parts.first().map(String::as_str) == Some("p"))
.then(|| parts.get(1).cloned())
.flatten()
})
.collect()
}
/// Fetch raw events for `filter` via the relay's `/query` endpoint.
/// Returns `None` on any I/O or parse failure.
async fn fetch_events(
@@ -478,6 +568,7 @@ pub struct SendMessageParams {
pub reply_to: Option<String>,
pub broadcast: bool,
pub files: Vec<String>,
pub mentions: Vec<String>,
}
pub async fn cmd_send_message(
@@ -495,6 +586,30 @@ pub async fn cmd_send_message(
}
let channel_uuid = parse_uuid(&p.channel_id)?;
let explicit_mentions = normalize_explicit_mentions(&p.mentions)?;
let stripped = strip_code_regions(&p.content);
let uri_pubkeys = extract_nostr_uris(&stripped);
// Supplying any identity explicitly authorizes unresolved or ambiguous @Name text
// as presentation-only, matching Desktop's separate visible-label and p-tag model.
// Uniquely resolvable member names still add their own p-tags; callers must supply
// every intended identity whose visible label cannot be resolved uniquely.
let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty();
let (member_pubkeys, auto_resolved) =
resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?;
let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?;
let missing = missing_members(&mention_pubkeys, &member_pubkeys);
if !missing.is_empty() {
return Err(CliError::Usage(
serde_json::json!({
"message": "mentioned pubkeys are not channel members; add them explicitly before retrying",
"missing_member_pubkeys": missing,
"add_member_command": format!("buzz channels add-member --channel {} --pubkey <pubkey> --role <member|bot>", p.channel_id),
})
.to_string(),
));
}
// Upload files and build imeta tags
let mut media_tags: Vec<Vec<String>> = Vec::new();
let mut media_content = String::new();
@@ -526,16 +641,7 @@ pub async fn cmd_send_message(
None
};
// Resolve @name mentions in the author-written body only — not the media markdown we
// append above, which is derived from upload metadata and can't carry `@names`.
let mut auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await;
// NIP-27: also extract nostr:npub1… inline references (skipping code regions)
let stripped = strip_code_regions(&p.content);
let uri_pubkeys = extract_nostr_uris(&stripped);
merge_mentions(&mut auto_resolved, &uri_pubkeys, MENTION_CAP);
let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect();
let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect();
let builder = match p.kind {
Some(45001) => {
@@ -572,9 +678,17 @@ pub async fn cmd_send_message(
};
let event = client.sign_event(builder)?;
let emitted_mentions = event_mention_pubkeys(&event);
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp))
.unwrap_or_else(|_| serde_json::json!({ "response": resp }));
if let Some(object) = output.as_object_mut() {
object.insert(
"mention_pubkeys".into(),
serde_json::json!(emitted_mentions),
);
}
println!("{output}");
Ok(())
}
@@ -765,6 +879,7 @@ pub async fn dispatch(
reply_to,
broadcast,
files,
mentions,
} => {
cmd_send_message(
client,
@@ -775,6 +890,7 @@ pub async fn dispatch(
reply_to,
broadcast,
files,
mentions,
},
)
.await
@@ -876,7 +992,11 @@ pub async fn dispatch(
#[cfg(test)]
mod tests {
use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys};
use super::{
event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions,
missing_members, normalize_explicit_mentions, parse_member_pubkeys,
resolve_names_to_pubkeys,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
};
@@ -1103,6 +1223,94 @@ mod tests {
assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]);
}
#[test]
fn explicit_mentions_accept_hex_and_npub_and_deduplicate() {
use nostr::ToBech32;
let npub = nostr::PublicKey::from_hex(PK_VALID_A)
.unwrap()
.to_bech32()
.unwrap();
assert_eq!(
normalize_explicit_mentions(&[PK_VALID_A.into(), npub]).unwrap(),
vec![PK_VALID_A]
);
assert!(normalize_explicit_mentions(&["not-a-key".into()]).is_err());
}
#[test]
fn explicit_mentions_authorize_presentation_text_without_name_resolution() {
let names = vec!["renamed user".into()];
let profiles = std::collections::HashMap::new();
assert_eq!(
resolve_names_to_pubkeys(&names, &profiles, true).unwrap(),
Vec::<String>::new()
);
assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err());
}
#[test]
fn explicit_mentions_authorize_ambiguous_presentation_text() {
let names = vec!["alice".into()];
let profiles = std::collections::HashMap::from([(
"alice".into(),
vec![PK_VALID_A.into(), PK_VALID_B.into()],
)]);
assert_eq!(
resolve_names_to_pubkeys(&names, &profiles, true).unwrap(),
Vec::<String>::new()
);
let error = resolve_names_to_pubkeys(&names, &profiles, false).unwrap_err();
assert!(error.to_string().contains(PK_VALID_A));
assert!(error.to_string().contains(PK_VALID_B));
}
#[test]
fn explicit_mentions_make_all_at_names_presentation_only() {
let names = vec!["alice".into(), "bob".into()];
let profiles = std::collections::HashMap::from([("alice".into(), vec![PK_VALID_A.into()])]);
assert_eq!(
resolve_names_to_pubkeys(&names, &profiles, true).unwrap(),
vec![PK_VALID_A]
);
assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err());
}
#[test]
fn combined_mention_union_errors_instead_of_truncating() {
let explicit: Vec<String> = (0..50).map(|i| format!("explicit-{i}")).collect();
assert!(merge_message_mentions(&explicit, &[], &["resolved-bob".into()]).is_err());
let mut with_duplicate = explicit.clone();
with_duplicate.push(explicit[0].clone());
assert_eq!(
merge_message_mentions(&with_duplicate, &[explicit[1].clone()], &[])
.unwrap()
.len(),
50
);
}
#[test]
fn membership_preflight_lists_only_missing_mentions() {
assert_eq!(
missing_members(
&[PK_VALID_A.into(), PK_VALID_B.into()],
&[PK_VALID_A.into()]
),
vec![PK_VALID_B]
);
}
#[test]
fn mention_evidence_comes_from_signed_event_tags() {
use nostr::{EventBuilder, Keys, Tag};
let event = EventBuilder::text_note("hello")
.tags(vec![Tag::parse(["p", PK_VALID_A]).unwrap()])
.sign_with_keys(&Keys::generate())
.unwrap();
assert_eq!(event_mention_pubkeys(&event), vec![PK_VALID_A]);
}
// ---- match_profiles_by_name (author resolution for `messages search --author`) ----
fn profile_event(
+3
View File
@@ -369,6 +369,9 @@ pub enum MessagesCmd {
/// Attach file(s) — uploads and includes as imeta tags
#[arg(long = "file")]
files: Vec<String>,
/// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify.
#[arg(long = "mention")]
mentions: Vec<String>,
},
/// Send a code diff / patch to a channel
SendDiff {
+1 -1
View File
@@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4;
/// Template content version for SKILL.md.
/// Bump this when changing `nest_skill.md` to trigger refresh on existing installs.
const NEST_SKILL_VERSION: u32 = 4;
const NEST_SKILL_VERSION: u32 = 5;
const BEGIN_MARKER: &str = "<!-- BEGIN BUZZ MANAGED";
const END_MARKER: &str = "<!-- END BUZZ MANAGED -->";
@@ -29,6 +29,18 @@ fn init_nest_dir_prod_sets_buzz() {
}
}
#[test]
fn nest_skill_contains_safe_mention_workflow() {
assert!(BUZZ_CLI_SKILL_MD.contains("--mention <hex-or-npub>"));
assert!(BUZZ_CLI_SKILL_MD.contains("every presentation-only name that should notify"));
assert!(BUZZ_CLI_SKILL_MD
.contains("permits unresolved or ambiguous `@Name` text as presentation-only"));
assert!(BUZZ_CLI_SKILL_MD.contains("signed event's `mention_pubkeys`"));
assert!(BUZZ_CLI_SKILL_MD.contains("no follow-up verification command is needed"));
assert!(BUZZ_CLI_SKILL_MD.contains("Add membership separately only when authorized"));
assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically"));
}
#[test]
fn ensure_nest_creates_all_dirs_and_agents_md() {
let tmp = tempfile::tempdir().unwrap();
@@ -87,14 +87,11 @@ Write commands are unaffected. `--format json` (default) returns full fields.
## Communication Patterns
**Mentions that notify:** Use `@Name` directly in message content — the CLI auto-resolves channel members by name and adds the required p-tags. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to p-tags without needing a flag.
**Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention <hex-or-npub>`. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically.
```bash
# ✅ Correct — notification delivered automatically
buzz messages send --channel <UUID> --content "@Alice check this"
# Multiple mentions — same pattern
buzz messages send --channel <UUID> --content "@Alice @Bob review please"
buzz messages send --channel <UUID> \
--content "@Alice check this" --mention <alice-pubkey>
```
## DM Management