Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ All replies and delegations — including task assignments to other agents — g

- Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need.
- **If your turn produced anything worth knowing, you MUST publish it.** Use `buzz messages send`. Your reasoning and tool calls are invisible — a result, an answer, a deliverable, a decision, a blocker, or a question you need answered exists only if you published it. Work or an answer that someone asked you for always counts. Ending that kind of turn without a message is a silent failure.
- When completed work produced a shareable file, attach that file to the result message with repeatable `buzz messages send --file <path> --outbox`. The `--outbox` marker makes completed files appear in the human's Outbox; a local path mentioned only in message text does not.
- **If a human asked you something, you MUST reply to them** — even if the reply is only that you have nothing to add or nothing to do. Never leave a person waiting on you.
- **Otherwise, publishing is optional and silence is usually correct.** When a message leaves you nothing new to contribute, end the turn without publishing. That is a success, not a failure.
- **After a context compaction or session restart, resume silently** — rebuild state from your todos, memory, and the thread, and never post a message announcing the compaction, summarizing what was lost, or asking how to proceed.
Expand Down
8 changes: 8 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4477,6 +4477,14 @@ mod agent_draft_prompt_tests {
assert!(prompt.contains("buzz messages send ... --content -"));
}

#[test]
fn shared_base_prompt_routes_completed_files_to_outbox() {
let prompt = include_str!("base_prompt.md");
assert!(prompt.contains("buzz messages send --file <path> --outbox"));
assert!(prompt.contains("marker makes completed files appear"));
assert!(prompt.contains("local path mentioned only in message text does not"));
}

#[test]
fn shared_base_prompt_teaches_repo_context_and_learning_loop() {
let prompt = include_str!("base_prompt.md");
Expand Down
27 changes: 24 additions & 3 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub struct BlobDescriptor {
}

/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata).
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
pub fn build_imeta_tag(d: &BlobDescriptor, filename: Option<&str>) -> Vec<String> {
let mut tag = vec![
"imeta".to_string(),
format!("url {}", d.url),
Expand All @@ -57,6 +57,9 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
if let Some(dur) = d.duration {
tag.push(format!("duration {dur}"));
}
if let Some(filename) = filename.filter(|value| !value.trim().is_empty()) {
tag.push(format!("filename {}", filename.trim()));
}
tag
}

Expand Down Expand Up @@ -2307,11 +2310,29 @@ mod retry_policy_tests {
#[cfg(test)]
mod tests {
use super::{
advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field,
normalize_events, BuzzClient,
advance_query_cursor, build_imeta_tag, create_response_with_id_if_accepted,
extract_relay_response_field, normalize_events, BlobDescriptor, BuzzClient,
};
use nostr::{EventBuilder, Keys, Kind, Tag};

#[test]
fn imeta_preserves_the_attached_filename_for_outbox_display() {
let descriptor = BlobDescriptor {
url: "https://relay.example/media/abc".into(),
sha256: "a".repeat(64),
size: 42,
mime_type: "application/pdf".into(),
uploaded: 1_700_000_000,
dim: None,
blurhash: None,
thumb: None,
duration: None,
};

assert!(build_imeta_tag(&descriptor, Some("FINAL_REPORT.pdf"))
.contains(&"filename FINAL_REPORT.pdf".to_string()));
}

#[test]
fn normalize_events_preserves_the_complete_signed_event_shape() {
let signed_event = EventBuilder::new(Kind::TextNote, "signed content")
Expand Down
19 changes: 16 additions & 3 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection};
use nostr::PublicKey;
use nostr::{PublicKey, Tag};
use std::path::Path;
use uuid::Uuid;

use crate::client::{normalize_events, normalize_write_response, BuzzClient};
Expand Down Expand Up @@ -605,6 +606,7 @@ pub struct SendMessageParams {
pub reply_to: Option<String>,
pub broadcast: bool,
pub files: Vec<String>,
pub outbox: bool,
pub mentions: Vec<String>,
}

Expand Down Expand Up @@ -655,7 +657,10 @@ pub async fn cmd_send_message(
.upload_file(file_path)
.await
.map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?;
media_tags.push(crate::client::build_imeta_tag(&desc));
let filename = Path::new(file_path)
.file_name()
.and_then(|value| value.to_str());
media_tags.push(crate::client::build_imeta_tag(&desc, filename));
if desc.mime_type.starts_with("video/") {
media_content.push_str("\n![video](");
} else {
Expand All @@ -680,7 +685,7 @@ pub async fn cmd_send_message(

let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect();

let builder = match p.kind {
let mut builder = match p.kind {
Some(45001) => {
buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags)
.map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))?
Expand Down Expand Up @@ -713,6 +718,12 @@ pub async fn cmd_send_message(
)))
}
};
if p.outbox {
builder = builder.tag(
Tag::parse(["buzz-outbox", "1"])
.map_err(|error| CliError::Other(format!("build outbox tag failed: {error}")))?,
);
}

let event = client.sign_event(builder)?;
let emitted_mentions = event_mention_pubkeys(&event);
Expand Down Expand Up @@ -916,6 +927,7 @@ pub async fn dispatch(
reply_to,
broadcast,
files,
outbox,
mentions,
} => {
cmd_send_message(
Expand All @@ -927,6 +939,7 @@ pub async fn dispatch(
reply_to,
broadcast,
files,
outbox,
mentions,
},
)
Expand Down
34 changes: 33 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ buzz agents archived"
pub enum MessagesCmd {
/// Send a message to a channel
#[command(
after_help = "Examples:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -"
after_help = "Examples:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n buzz messages send --channel <UUID> --content \"Completed\" --file ./report.pdf --outbox\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -"
)]
Send {
/// Channel UUID (from 'buzz channels list')
Expand All @@ -392,6 +392,9 @@ pub enum MessagesCmd {
/// Attach file(s) — uploads and includes as imeta tags
#[arg(long = "file")]
files: Vec<String>,
/// Mark attached files as completed deliverables for the human's Outbox
#[arg(long, default_value_t = false, requires = "files")]
outbox: bool,
/// 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>,
Expand Down Expand Up @@ -2167,6 +2170,35 @@ mod tests {
.is_err());
}

#[test]
fn messages_send_outbox_requires_an_attached_file() {
let channel = "123e4567-e89b-12d3-a456-426614174000";
assert!(Cli::try_parse_from([
"buzz",
"messages",
"send",
"--channel",
channel,
"--content",
"Complete",
"--file",
"report.pdf",
"--outbox",
])
.is_ok());
assert!(Cli::try_parse_from([
"buzz",
"messages",
"send",
"--channel",
channel,
"--content",
"Complete",
"--outbox",
])
.is_err());
}

#[test]
fn set_status_clear_rejects_text_and_emoji() {
for extra in [["--text", "busy"], ["--emoji", "🎶"]] {
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export default defineConfig({
"**/drafts-screenshots.spec.ts",
"**/drafts-all-fix-screenshots.spec.ts",
"**/inbox-refactor-screenshots.spec.ts",
"**/outbox.spec.ts",
"**/buzz-theme-screenshots.spec.ts",
"**/appearance-previews.spec.ts",
"**/channel-sort.spec.ts",
Expand Down
39 changes: 39 additions & 0 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tauri::State;
use tauri_plugin_opener::OpenerExt;

use crate::app_state::AppState;
use crate::commands::clipboard::with_clipboard;
Expand Down Expand Up @@ -139,6 +140,44 @@ pub async fn download_file(
save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await
}

/// Download a relay-hosted artifact into an isolated temporary directory and
/// open it with the operating system's default application.
///
/// Outbox uses this instead of a bare webview link so one click reaches the
/// actual artifact without handing authenticated media URLs to an external
/// browser. The same origin, size, and MIME safeguards as `download_file`
/// apply before any bytes are written locally.
#[tauri::command]
pub async fn open_artifact(
url: String,
filename: String,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let relay_base = relay_api_base_url_with_override(&state);
validate_download_url(&url, &relay_base)?;

let filename = sanitize_filename(&filename);
let bytes = fetch_blob_bytes(&url, &state).await?;
detect_and_validate_mime(&bytes)?;

let artifact_dir = std::env::temp_dir().join(format!(
"buzz-artifact-{}",
uuid::Uuid::new_v4().simple()
));
tokio::fs::create_dir_all(&artifact_dir)
.await
.map_err(|error| format!("create artifact directory: {error}"))?;
let artifact_path = artifact_dir.join(filename);
tokio::fs::write(&artifact_path, bytes)
.await
.map_err(|error| format!("write artifact: {error}"))?;

app.opener()
.open_path(artifact_path.to_string_lossy(), None::<&str>)
.map_err(|error| format!("open artifact: {error}"))
}

/// Fetch relay media bytes for the composer image editor.
///
/// The editor composites the image onto a canvas and needs pixel access.
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ pub fn run() {
download_image,
save_png_data_url,
download_file,
open_artifact,
fetch_media_bytes,
copy_image_to_clipboard,
copy_text_to_clipboard,
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/app/AppShell.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SearchHit } from "@/shared/api/types";

export type AppView =
| "home"
| "outbox"
| "channel"
| "messages"
| "agents"
Expand Down Expand Up @@ -233,6 +234,13 @@ export function deriveShellRoute(pathname: string): {
};
}

if (pathname === "/outbox") {
return {
selectedChannelId: null,
selectedView: "outbox",
};
}

if (pathname === "/agents") {
return {
selectedChannelId: null,
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export function AppShell() {
goChannel,
goHome,
goNewMessage,
goOutbox,
goProjects,
goPulse,
goSettings,
Expand Down Expand Up @@ -880,6 +881,7 @@ export function AppShell() {
scopeSearchFocusRequest,
]}
onSelectHome={() => void goHome()}
onSelectOutbox={() => void goOutbox()}
onSelectProjects={() => void goProjects()}
onSelectPulse={() => void goPulse()}
onSelectSettings={handleOpenSettings}
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ export function useAppNavigation() {
[commitNavigation],
);

const goOutbox = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/outbox",
},
behavior,
),
[commitNavigation],
);

const goAgents = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
Expand Down Expand Up @@ -465,6 +476,7 @@ export function useAppNavigation() {
goNewMessage,
goNewWorkflow,
goNewWorkflowForChannel,
goOutbox,
goProject,
goProjects,
goPulse,
Expand Down
Loading
Loading