From 7c0b19bb2a4e0750c197f72a4dc2ab4df43e1de1 Mon Sep 17 00:00:00 2001 From: npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz <4b78193083ca25112966f20d4368247d44e3965ba7219497e4cf6a98b308b9c0@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 12:07:17 -0400 Subject: [PATCH 1/6] fix(agents): guide safe mentions and forum posts Signed-off-by: npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz <4b78193083ca25112966f20d4368247d44e3965ba7219497e4cf6a98b308b9c0@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 7 +++++++ crates/buzz-acp/src/lib.rs | 19 +++++++++++++++++++ crates/buzz-cli/src/lib.rs | 2 +- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../src/managed_agents/nest/tests.rs | 14 ++++++++++++++ .../src/managed_agents/nest_skill.md | 19 +++++++++++++++---- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index c42e65cb83..bc6bf1e92d 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -40,6 +40,9 @@ 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. +- Before sending a message with an `@mention`, search for that person with `buzz users get --name ""`, then select the pubkey only when exactly one result's full `display_name` or `name` matches case-insensitively. If there is no unique exact match, report the ambiguity instead of guessing. Inspect `buzz channels members --channel ` using that pubkey; the CLI only resolves **current channel members** into `p` tags. +- If the intended person is absent, add them only when you are authorized: `buzz channels add-member --channel --pubkey `. Otherwise, report that you cannot safely deliver the notification. Do not send a visible `@Name` as a substitute for a `p` tag. +- After sending a notification-bearing message, inspect the emitted event (`buzz messages get --channel `) and verify its tags include `p` plus the intended exact pubkey. A visible `@Name` alone is not a delivered mention. - 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 @@ -59,6 +62,10 @@ When in doubt, prefer the reply destination explicitly supplied in `[Context]`. All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it. +### Forum Channels + +Forum channels are not stream channels. For a new forum thread, send kind `45001`: `buzz messages send --channel --kind 45001 --content "..."`. For a reply to a forum thread, send kind `45003` **and** the supplied `--reply-to `. Do not use the stream default kind `9` for either forum roots or replies. + ### General - Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d63f720c65..f35b0b5b90 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3625,6 +3625,25 @@ 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_requires_membership_and_p_tag_verification_for_mentions() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("buzz users get --name \"\"")); + assert!(prompt.contains("If there is no unique exact match, report the ambiguity")); + assert!(prompt.contains("buzz channels members --channel ")); + assert!(prompt.contains("buzz channels add-member --channel --pubkey ")); + assert!(prompt.contains("A visible `@Name` alone is not a delivered mention.")); + } + + #[test] + fn shared_base_prompt_distinguishes_forum_kinds_from_stream_messages() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("Forum channels are not stream channels.")); + assert!(prompt.contains("kind `45001`")); + assert!(prompt.contains("kind `45003`")); + assert!(prompt.contains("stream default kind `9`")); + } } fn default_heartbeat_prompt() -> String { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0b46734584..c152f6ae09 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -348,7 +348,7 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n buzz messages send --channel --kind 45001 --content \"new forum thread\"\n buzz messages send --channel --kind 45003 --reply-to --content \"forum reply\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" )] Send { /// Channel UUID (from 'buzz channels list') diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index e13ab2baad..c8f008836d 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -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 = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index d2c415e725..ba65ac61e4 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -29,6 +29,20 @@ fn init_nest_dir_prod_sets_buzz() { } } +#[test] +fn nest_skill_contains_safe_mention_and_forum_workflows() { + assert!(BUZZ_CLI_SKILL_MD.contains("current channel members")); + assert!(BUZZ_CLI_SKILL_MD.contains("buzz users get --name \"\"")); + assert!(BUZZ_CLI_SKILL_MD.contains("If there is no unique exact match, report the ambiguity")); + assert!(BUZZ_CLI_SKILL_MD.contains("buzz channels members --channel ")); + assert!(BUZZ_CLI_SKILL_MD.contains("buzz channels add-member --channel --pubkey ")); + assert!(BUZZ_CLI_SKILL_MD + .contains("Visible `@Name` text without that tag does **not** notify anyone.")); + assert!(BUZZ_CLI_SKILL_MD.contains("forum root as kind `45001`")); + assert!(BUZZ_CLI_SKILL_MD.contains("forum reply as kind `45003`")); + assert!(BUZZ_CLI_SKILL_MD.contains("Never use stream kind `9`")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index fefdfa77fa..6b4ec0d457 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -87,14 +87,25 @@ 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:** The CLI auto-resolves only **current channel members** by name and adds their required `p` tags. Before sending `@Name`, search with `buzz users get --name ""`, then select the pubkey only when exactly one result's full `display_name` or `name` matches case-insensitively. If there is no unique exact match, report the ambiguity instead of guessing. Inspect `buzz channels members --channel ` using that pubkey. If the person is absent, add them only when authorized: `buzz channels add-member --channel --pubkey `; otherwise report the delivery blocker. After sending, inspect `buzz messages get --channel ` and verify the emitted event contains `p` plus that exact pubkey. Visible `@Name` text without that tag does **not** notify anyone. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to `p` tags when applicable. ```bash -# ✅ Correct — notification delivered automatically +# Search by exact full display name; proceed only with one exact result +buzz users get --name "" +buzz channels members --channel + +# Add only when authorized, then send and verify the p-tag in the event +buzz channels add-member --channel --pubkey buzz messages send --channel --content "@Alice check this" +buzz messages get --channel --limit 1 +``` -# Multiple mentions — same pattern -buzz messages send --channel --content "@Alice @Bob review please" +**Forum messages:** Forum roots and comments are distinct from stream messages. Send a forum root as kind `45001`; send a forum reply as kind `45003` with `--reply-to `. Never use stream kind `9` for a forum root or reply. + +```bash +buzz messages send --channel --kind 45001 --content "New discussion" +buzz messages send --channel --kind 45003 \ + --reply-to --content "Reply" ``` ## DM Management From 5816624638e7f69737cdef9e8aa7eb78adcfcacf Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 16:34:19 -0400 Subject: [PATCH 2/6] fix(agents): make mention verification exact Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 4 ++-- crates/buzz-acp/src/lib.rs | 2 ++ desktop/src-tauri/src/managed_agents/nest/tests.rs | 4 ++++ desktop/src-tauri/src/managed_agents/nest_skill.md | 11 +++++++---- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index bc6bf1e92d..9a785d7204 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -40,9 +40,9 @@ 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. -- Before sending a message with an `@mention`, search for that person with `buzz users get --name ""`, then select the pubkey only when exactly one result's full `display_name` or `name` matches case-insensitively. If there is no unique exact match, report the ambiguity instead of guessing. Inspect `buzz channels members --channel ` using that pubkey; the CLI only resolves **current channel members** into `p` tags. +- Before sending a message with an `@mention`, search for that person with `buzz users get --name ""`. For each result, the mentionable label is its non-empty string `display_name` when that field is present; only when `display_name` is absent may a non-empty string `name` be used. A present but empty/invalid `display_name` does not fall back to `name`. Select the pubkey only when exactly one result's mentionable label is a case-insensitive exact match. If there is no unique exact match, report the ambiguity instead of guessing. Use that exact label after `@`, then inspect `buzz channels members --channel ` using that pubkey; the CLI only resolves **current channel members** into `p` tags. - If the intended person is absent, add them only when you are authorized: `buzz channels add-member --channel --pubkey `. Otherwise, report that you cannot safely deliver the notification. Do not send a visible `@Name` as a substitute for a `p` tag. -- After sending a notification-bearing message, inspect the emitted event (`buzz messages get --channel `) and verify its tags include `p` plus the intended exact pubkey. A visible `@Name` alone is not a delivered mention. +- After sending a notification-bearing message, capture the `event_id` from `buzz messages send`, then query that exact event with `buzz messages thread --channel --event ` and verify the event with that ID has a `p` tag containing the intended exact pubkey. Do not verify via a channel-latest query such as `messages get --limit 1`; another writer may race it. A visible `@Name` alone is not a delivered mention. - 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 diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index f35b0b5b90..07c2527560 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3631,8 +3631,10 @@ mod agent_draft_prompt_tests { let prompt = include_str!("base_prompt.md"); assert!(prompt.contains("buzz users get --name \"\"")); assert!(prompt.contains("If there is no unique exact match, report the ambiguity")); + assert!(prompt.contains("empty/invalid `display_name` does not fall back")); assert!(prompt.contains("buzz channels members --channel ")); assert!(prompt.contains("buzz channels add-member --channel --pubkey ")); + assert!(prompt.contains("buzz messages thread --channel --event ")); assert!(prompt.contains("A visible `@Name` alone is not a delivered mention.")); } diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index ba65ac61e4..8f5b1369d8 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -34,8 +34,12 @@ fn nest_skill_contains_safe_mention_and_forum_workflows() { assert!(BUZZ_CLI_SKILL_MD.contains("current channel members")); assert!(BUZZ_CLI_SKILL_MD.contains("buzz users get --name \"\"")); assert!(BUZZ_CLI_SKILL_MD.contains("If there is no unique exact match, report the ambiguity")); + assert!(BUZZ_CLI_SKILL_MD.contains("empty/invalid `display_name` does not fall back")); assert!(BUZZ_CLI_SKILL_MD.contains("buzz channels members --channel ")); assert!(BUZZ_CLI_SKILL_MD.contains("buzz channels add-member --channel --pubkey ")); + assert!( + BUZZ_CLI_SKILL_MD.contains("buzz messages thread --channel --event \"$event_id\"") + ); assert!(BUZZ_CLI_SKILL_MD .contains("Visible `@Name` text without that tag does **not** notify anyone.")); assert!(BUZZ_CLI_SKILL_MD.contains("forum root as kind `45001`")); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 6b4ec0d457..dd9b7efe66 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -87,17 +87,20 @@ Write commands are unaffected. `--format json` (default) returns full fields. ## Communication Patterns -**Mentions that notify:** The CLI auto-resolves only **current channel members** by name and adds their required `p` tags. Before sending `@Name`, search with `buzz users get --name ""`, then select the pubkey only when exactly one result's full `display_name` or `name` matches case-insensitively. If there is no unique exact match, report the ambiguity instead of guessing. Inspect `buzz channels members --channel ` using that pubkey. If the person is absent, add them only when authorized: `buzz channels add-member --channel --pubkey `; otherwise report the delivery blocker. After sending, inspect `buzz messages get --channel ` and verify the emitted event contains `p` plus that exact pubkey. Visible `@Name` text without that tag does **not** notify anyone. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to `p` tags when applicable. +**Mentions that notify:** The CLI auto-resolves only **current channel members** by name and adds their required `p` tags. Before sending `@Name`, search with `buzz users get --name ""`. For each result, its mentionable label is a non-empty string `display_name` when that field is present; only when `display_name` is absent may a non-empty string `name` be used. A present but empty/invalid `display_name` does not fall back to `name`. Select the pubkey only when exactly one result's mentionable label is a case-insensitive exact match. If there is no unique exact match, report the ambiguity instead of guessing. Use that exact label after `@`, then inspect `buzz channels members --channel ` using the pubkey. If the person is absent, add them only when authorized: `buzz channels add-member --channel --pubkey `; otherwise report the delivery blocker. After sending, capture its returned `event_id`, query that exact event with `buzz messages thread --channel --event `, and verify that event contains `p` plus the exact pubkey. Never verify with channel-latest state such as `messages get --limit 1`, which another writer can race. Visible `@Name` text without that tag does **not** notify anyone. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to `p` tags when applicable. ```bash # Search by exact full display name; proceed only with one exact result buzz users get --name "" buzz channels members --channel -# Add only when authorized, then send and verify the p-tag in the event +# Add only when authorized, then send with the exact mentionable label buzz channels add-member --channel --pubkey -buzz messages send --channel --content "@Alice check this" -buzz messages get --channel --limit 1 +send_result=$(buzz messages send --channel --content "@Alice check this") +event_id=$(printf '%s' "$send_result" | jq -r '.event_id') + +# Query that exact event, not whichever channel message is newest +buzz messages thread --channel --event "$event_id" ``` **Forum messages:** Forum roots and comments are distinct from stream messages. Send a forum root as kind `45001`; send a forum reply as kind `45003` with `--reply-to `. Never use stream kind `9` for a forum root or reply. From 6eb83503dcd3d7daf797a366a83121500f1af061 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 11:04:27 -0400 Subject: [PATCH 3/6] feat(cli): add deterministic message mentions Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 5 +- crates/buzz-acp/src/lib.rs | 15 +- crates/buzz-cli/src/commands/messages.rs | 241 +++++++++++++++--- crates/buzz-cli/src/lib.rs | 6 + .../src/managed_agents/nest/tests.rs | 16 +- .../src/managed_agents/nest_skill.md | 15 +- 6 files changed, 223 insertions(+), 75 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 9a785d7204..ee3d31334d 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -40,9 +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. -- Before sending a message with an `@mention`, search for that person with `buzz users get --name ""`. For each result, the mentionable label is its non-empty string `display_name` when that field is present; only when `display_name` is absent may a non-empty string `name` be used. A present but empty/invalid `display_name` does not fall back to `name`. Select the pubkey only when exactly one result's mentionable label is a case-insensitive exact match. If there is no unique exact match, report the ambiguity instead of guessing. Use that exact label after `@`, then inspect `buzz channels members --channel ` using that pubkey; the CLI only resolves **current channel members** into `p` tags. -- If the intended person is absent, add them only when you are authorized: `buzz channels add-member --channel --pubkey `. Otherwise, report that you cannot safely deliver the notification. Do not send a visible `@Name` as a substitute for a `p` tag. -- After sending a notification-bearing message, capture the `event_id` from `buzz messages send`, then query that exact event with `buzz messages thread --channel --event ` and verify the event with that ID has a `p` tag containing the intended exact pubkey. Do not verify via a channel-latest query such as `messages get --limit 1`; another writer may race it. A visible `@Name` alone is not a delivered mention. +- When you know the intended recipient's pubkey, send the readable `@Name` in content and pass the identity separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. 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, either add them explicitly with `buzz channels add-member` when authorized and retry, or intentionally notify without adding by retrying with `--allow-non-member-mentions`. 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 diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 07c2527560..683061da62 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3627,15 +3627,14 @@ mod agent_draft_prompt_tests { } #[test] - fn shared_base_prompt_requires_membership_and_p_tag_verification_for_mentions() { + fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); - assert!(prompt.contains("buzz users get --name \"\"")); - assert!(prompt.contains("If there is no unique exact match, report the ambiguity")); - assert!(prompt.contains("empty/invalid `display_name` does not fall back")); - assert!(prompt.contains("buzz channels members --channel ")); - assert!(prompt.contains("buzz channels add-member --channel --pubkey ")); - assert!(prompt.contains("buzz messages thread --channel --event ")); - assert!(prompt.contains("A visible `@Name` alone is not a delivered mention.")); + assert!(prompt.contains("--mention ")); + 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("--allow-non-member-mentions")); + assert!(prompt.contains("never changes membership automatically")); } #[test] diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..c2e363adb2 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -119,47 +119,82 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result>, + has_explicit_mentions: bool, +) -> Result, 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 " + ))) + } + _ if has_explicit_mentions => {} + candidates => { + return Err(CliError::Usage(format!( + "mention '@{name}' is ambiguous; candidates: {}. Retry with --mention ", + 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 { - if !content.contains('@') { - return vec![]; + has_explicit_mentions: bool, +) -> Result<(Vec, Vec), 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> = std::collections::HashMap::new(); - let mut display_names: Vec = 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 +213,59 @@ 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)) +} + +fn normalize_explicit_mentions(values: &[String]) -> Result, 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) +} - // 5. Look up matched names → pubkeys via the map we already built. - names +fn missing_members(mentions: &[String], members: &[String]) -> Vec { + let members: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect(); + mentions .iter() - .flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten()) + .filter(|pk| !members.contains(pk.as_str())) .cloned() .collect() } +fn event_mention_pubkeys(event: &nostr::Event) -> Vec { + 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 +546,8 @@ pub struct SendMessageParams { pub reply_to: Option, pub broadcast: bool, pub files: Vec, + pub mentions: Vec, + pub allow_non_member_mentions: bool, } pub async fn cmd_send_message( @@ -495,6 +565,27 @@ pub async fn cmd_send_message( } let channel_uuid = parse_uuid(&p.channel_id)?; + let mut mention_pubkeys = normalize_explicit_mentions(&p.mentions)?; + let stripped = strip_code_regions(&p.content); + let uri_pubkeys = extract_nostr_uris(&stripped); + let has_explicit_mentions = !mention_pubkeys.is_empty() || !uri_pubkeys.is_empty(); + let (member_pubkeys, auto_resolved) = + resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?; + merge_mentions(&mut mention_pubkeys, &uri_pubkeys, MENTION_CAP); + merge_mentions(&mut mention_pubkeys, &auto_resolved, MENTION_CAP); + + let missing = missing_members(&mention_pubkeys, &member_pubkeys); + if !missing.is_empty() && !p.allow_non_member_mentions { + return Err(CliError::Usage( + serde_json::json!({ + "message": "mentioned pubkeys are not channel members; add them explicitly or retry with --allow-non-member-mentions", + "missing_member_pubkeys": missing, + "add_member_command": format!("buzz channels add-member --channel {} --pubkey --role ", p.channel_id), + }) + .to_string(), + )); + } + // Upload files and build imeta tags let mut media_tags: Vec> = Vec::new(); let mut media_content = String::new(); @@ -526,16 +617,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 +654,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 +855,8 @@ pub async fn dispatch( reply_to, broadcast, files, + mentions, + allow_non_member_mentions, } => { cmd_send_message( client, @@ -775,6 +867,8 @@ pub async fn dispatch( reply_to, broadcast, files, + mentions, + allow_non_member_mentions, }, ) .await @@ -876,7 +970,10 @@ 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, 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 +1200,68 @@ 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::::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::::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 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( diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c152f6ae09..bab4f7e169 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -369,6 +369,12 @@ pub enum MessagesCmd { /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, + /// Pubkey to mention (hex or npub; repeatable) + #[arg(long = "mention")] + mentions: Vec, + /// Permit notifying pubkeys that are not current channel members + #[arg(long, default_value_t = false)] + allow_non_member_mentions: bool, }, /// Send a code diff / patch to a channel SendDiff { diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 8f5b1369d8..7bd006a045 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -31,17 +31,11 @@ fn init_nest_dir_prod_sets_buzz() { #[test] fn nest_skill_contains_safe_mention_and_forum_workflows() { - assert!(BUZZ_CLI_SKILL_MD.contains("current channel members")); - assert!(BUZZ_CLI_SKILL_MD.contains("buzz users get --name \"\"")); - assert!(BUZZ_CLI_SKILL_MD.contains("If there is no unique exact match, report the ambiguity")); - assert!(BUZZ_CLI_SKILL_MD.contains("empty/invalid `display_name` does not fall back")); - assert!(BUZZ_CLI_SKILL_MD.contains("buzz channels members --channel ")); - assert!(BUZZ_CLI_SKILL_MD.contains("buzz channels add-member --channel --pubkey ")); - assert!( - BUZZ_CLI_SKILL_MD.contains("buzz messages thread --channel --event \"$event_id\"") - ); - assert!(BUZZ_CLI_SKILL_MD - .contains("Visible `@Name` text without that tag does **not** notify anyone.")); + assert!(BUZZ_CLI_SKILL_MD.contains("--mention ")); + 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("--allow-non-member-mentions")); + assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); assert!(BUZZ_CLI_SKILL_MD.contains("forum root as kind `45001`")); assert!(BUZZ_CLI_SKILL_MD.contains("forum reply as kind `45003`")); assert!(BUZZ_CLI_SKILL_MD.contains("Never use stream kind `9`")); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index dd9b7efe66..d53823a345 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -87,20 +87,11 @@ Write commands are unaffected. `--format json` (default) returns full fields. ## Communication Patterns -**Mentions that notify:** The CLI auto-resolves only **current channel members** by name and adds their required `p` tags. Before sending `@Name`, search with `buzz users get --name ""`. For each result, its mentionable label is a non-empty string `display_name` when that field is present; only when `display_name` is absent may a non-empty string `name` be used. A present but empty/invalid `display_name` does not fall back to `name`. Select the pubkey only when exactly one result's mentionable label is a case-insensitive exact match. If there is no unique exact match, report the ambiguity instead of guessing. Use that exact label after `@`, then inspect `buzz channels members --channel ` using the pubkey. If the person is absent, add them only when authorized: `buzz channels add-member --channel --pubkey `; otherwise report the delivery blocker. After sending, capture its returned `event_id`, query that exact event with `buzz messages thread --channel --event `, and verify that event contains `p` plus the exact pubkey. Never verify with channel-latest state such as `messages get --limit 1`, which another writer can race. Visible `@Name` text without that tag does **not** notify anyone. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to `p` tags when applicable. +**Mentions that notify:** Keep the readable `@Name` in message content and, when the intended pubkey is known, pass it in the same send with repeatable `--mention `. The CLI emits those exact `p` tags and reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without `--mention`, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, or retry with `--allow-non-member-mentions` to notify without adding; sending never changes membership automatically. ```bash -# Search by exact full display name; proceed only with one exact result -buzz users get --name "" -buzz channels members --channel - -# Add only when authorized, then send with the exact mentionable label -buzz channels add-member --channel --pubkey -send_result=$(buzz messages send --channel --content "@Alice check this") -event_id=$(printf '%s' "$send_result" | jq -r '.event_id') - -# Query that exact event, not whichever channel message is newest -buzz messages thread --channel --event "$event_id" +buzz messages send --channel \ + --content "@Alice check this" --mention ``` **Forum messages:** Forum roots and comments are distinct from stream messages. Send a forum root as kind `45001`; send a forum reply as kind `45003` with `--reply-to `. Never use stream kind `9` for a forum root or reply. From 0dfaed5e273fc1da8c8ea21341137149a8b9ed90 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 14:27:51 -0400 Subject: [PATCH 4/6] docs(agents): split forum guidance from mentions Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 4 ---- crates/buzz-acp/src/lib.rs | 9 --------- crates/buzz-cli/src/lib.rs | 2 +- desktop/src-tauri/src/managed_agents/nest/tests.rs | 5 +---- desktop/src-tauri/src/managed_agents/nest_skill.md | 8 -------- 5 files changed, 2 insertions(+), 26 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index ee3d31334d..69f22dbe7d 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -61,10 +61,6 @@ When in doubt, prefer the reply destination explicitly supplied in `[Context]`. All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it. -### Forum Channels - -Forum channels are not stream channels. For a new forum thread, send kind `45001`: `buzz messages send --channel --kind 45001 --content "..."`. For a reply to a forum thread, send kind `45003` **and** the supplied `--reply-to `. Do not use the stream default kind `9` for either forum roots or replies. - ### General - Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 683061da62..c7114acd93 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3636,15 +3636,6 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("--allow-non-member-mentions")); assert!(prompt.contains("never changes membership automatically")); } - - #[test] - fn shared_base_prompt_distinguishes_forum_kinds_from_stream_messages() { - let prompt = include_str!("base_prompt.md"); - assert!(prompt.contains("Forum channels are not stream channels.")); - assert!(prompt.contains("kind `45001`")); - assert!(prompt.contains("kind `45003`")); - assert!(prompt.contains("stream default kind `9`")); - } } fn default_heartbeat_prompt() -> String { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index bab4f7e169..7f10882a4d 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -348,7 +348,7 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n buzz messages send --channel --kind 45001 --content \"new forum thread\"\n buzz messages send --channel --kind 45003 --reply-to --content \"forum reply\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" )] Send { /// Channel UUID (from 'buzz channels list') diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 7bd006a045..7c63f3f1d2 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -30,15 +30,12 @@ fn init_nest_dir_prod_sets_buzz() { } #[test] -fn nest_skill_contains_safe_mention_and_forum_workflows() { +fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("--mention ")); 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("--allow-non-member-mentions")); assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); - assert!(BUZZ_CLI_SKILL_MD.contains("forum root as kind `45001`")); - assert!(BUZZ_CLI_SKILL_MD.contains("forum reply as kind `45003`")); - assert!(BUZZ_CLI_SKILL_MD.contains("Never use stream kind `9`")); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index d53823a345..1ee0edbb14 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -94,14 +94,6 @@ buzz messages send --channel \ --content "@Alice check this" --mention ``` -**Forum messages:** Forum roots and comments are distinct from stream messages. Send a forum root as kind `45001`; send a forum reply as kind `45003` with `--reply-to `. Never use stream kind `9` for a forum root or reply. - -```bash -buzz messages send --channel --kind 45001 --content "New discussion" -buzz messages send --channel --kind 45003 \ - --reply-to --content "Reply" -``` - ## DM Management `dms hide --channel ` hides a DM from the agent's DM list. Restore by re-opening with `dms open --pubkey `. From 743b92d4f7d7956d5dc9adcaa25913b8ceb89a32 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 15:01:43 -0400 Subject: [PATCH 5/6] fix(cli): reject mention union overflow Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 2 +- crates/buzz-acp/src/lib.rs | 4 ++ crates/buzz-cli/src/commands/messages.rs | 68 ++++++++++++++++--- crates/buzz-cli/src/lib.rs | 2 +- .../src/managed_agents/nest/tests.rs | 3 + .../src/managed_agents/nest_skill.md | 2 +- 6 files changed, 70 insertions(+), 11 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 69f22dbe7d..6ba14b1c59 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -40,7 +40,7 @@ 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 the intended recipient's pubkey, send the readable `@Name` in content and pass the identity separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. +- 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 `. 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, either add them explicitly with `buzz channels add-member` when authorized and retry, or intentionally notify without adding by retrying with `--allow-non-member-mentions`. 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. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index c7114acd93..16aabe0023 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3630,6 +3630,10 @@ mod agent_draft_prompt_tests { fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); assert!(prompt.contains("--mention ")); + 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")); diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index c2e363adb2..1299c2fecf 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -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. @@ -244,6 +243,29 @@ fn normalize_explicit_mentions(values: &[String]) -> Result, CliErro Ok(normalized) } +fn merge_message_mentions( + explicit: &[String], + uri_pubkeys: &[String], + auto_resolved: &[String], +) -> Result, CliError> { + let mut mentions = Vec::new(); + for pubkey in explicit + .iter() + .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 { let members: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect(); mentions @@ -565,14 +587,17 @@ pub async fn cmd_send_message( } let channel_uuid = parse_uuid(&p.channel_id)?; - let mut mention_pubkeys = normalize_explicit_mentions(&p.mentions)?; + let explicit_mentions = normalize_explicit_mentions(&p.mentions)?; let stripped = strip_code_regions(&p.content); let uri_pubkeys = extract_nostr_uris(&stripped); - let has_explicit_mentions = !mention_pubkeys.is_empty() || !uri_pubkeys.is_empty(); + // 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?; - merge_mentions(&mut mention_pubkeys, &uri_pubkeys, MENTION_CAP); - merge_mentions(&mut mention_pubkeys, &auto_resolved, MENTION_CAP); + let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; let missing = missing_members(&mention_pubkeys, &member_pubkeys); if !missing.is_empty() && !p.allow_non_member_mentions { @@ -971,8 +996,9 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, missing_members, - normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, + 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, @@ -1241,6 +1267,32 @@ mod tests { 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 = (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!( diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 7f10882a4d..5131a1fbd0 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -369,7 +369,7 @@ pub enum MessagesCmd { /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, - /// Pubkey to mention (hex or npub; repeatable) + /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity makes all @Name text presentation-only. #[arg(long = "mention")] mentions: Vec, /// Permit notifying pubkeys that are not current channel members diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 7c63f3f1d2..d74adaeb99 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -32,6 +32,9 @@ fn init_nest_dir_prod_sets_buzz() { #[test] fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("--mention ")); + 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("--allow-non-member-mentions")); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 1ee0edbb14..d957f38365 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -87,7 +87,7 @@ Write commands are unaffected. `--format json` (default) returns full fields. ## Communication Patterns -**Mentions that notify:** Keep the readable `@Name` in message content and, when the intended pubkey is known, pass it in the same send with repeatable `--mention `. The CLI emits those exact `p` tags and reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without `--mention`, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, or retry with `--allow-non-member-mentions` to notify without adding; sending never changes membership automatically. +**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 `. 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, or retry with `--allow-non-member-mentions` to notify without adding; sending never changes membership automatically. ```bash buzz messages send --channel \ From d14fdd0b40f1df7088ca495250f530f6f56a788e Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 15:57:29 -0400 Subject: [PATCH 6/6] fix(cli): require channel membership for mentions Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 2 +- crates/buzz-acp/src/lib.rs | 3 ++- crates/buzz-cli/src/commands/messages.rs | 7 ++----- crates/buzz-cli/src/lib.rs | 5 +---- desktop/src-tauri/src/managed_agents/nest/tests.rs | 2 +- desktop/src-tauri/src/managed_agents/nest_skill.md | 2 +- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 6ba14b1c59..e360d24982 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -41,7 +41,7 @@ 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 `. 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, either add them explicitly with `buzz channels add-member` when authorized and retry, or intentionally notify without adding by retrying with `--allow-non-member-mentions`. Sending never changes membership automatically. +- 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 diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 16aabe0023..403512a322 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3637,7 +3637,8 @@ mod agent_draft_prompt_tests { 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("--allow-non-member-mentions")); + assert!(prompt + .contains("add them explicitly with `buzz channels add-member` only when authorized")); assert!(prompt.contains("never changes membership automatically")); } } diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 1299c2fecf..40a9ae80b5 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -569,7 +569,6 @@ pub struct SendMessageParams { pub broadcast: bool, pub files: Vec, pub mentions: Vec, - pub allow_non_member_mentions: bool, } pub async fn cmd_send_message( @@ -600,10 +599,10 @@ pub async fn cmd_send_message( let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; let missing = missing_members(&mention_pubkeys, &member_pubkeys); - if !missing.is_empty() && !p.allow_non_member_mentions { + if !missing.is_empty() { return Err(CliError::Usage( serde_json::json!({ - "message": "mentioned pubkeys are not channel members; add them explicitly or retry with --allow-non-member-mentions", + "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 --role ", p.channel_id), }) @@ -881,7 +880,6 @@ pub async fn dispatch( broadcast, files, mentions, - allow_non_member_mentions, } => { cmd_send_message( client, @@ -893,7 +891,6 @@ pub async fn dispatch( broadcast, files, mentions, - allow_non_member_mentions, }, ) .await diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index cc43828921..df02c65be9 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -369,12 +369,9 @@ pub enum MessagesCmd { /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, - /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity makes all @Name text presentation-only. + /// 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, - /// Permit notifying pubkeys that are not current channel members - #[arg(long, default_value_t = false)] - allow_non_member_mentions: bool, }, /// Send a code diff / patch to a channel SendDiff { diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index d74adaeb99..031b049a49 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -37,7 +37,7 @@ fn nest_skill_contains_safe_mention_workflow() { .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("--allow-non-member-mentions")); + assert!(BUZZ_CLI_SKILL_MD.contains("Add membership separately only when authorized")); assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); } diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index d957f38365..79a5ea301d 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -87,7 +87,7 @@ Write commands are unaffected. `--format json` (default) returns full fields. ## Communication Patterns -**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 `. 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, or retry with `--allow-non-member-mentions` to notify without adding; sending never changes membership automatically. +**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 `. 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 buzz messages send --channel \