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
68 changes: 68 additions & 0 deletions crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,75 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
}

AgentsCmd::Archived => cmd_archived(client).await,

AgentsCmd::SetProfile {
name,
respond_to,
channels,
} => cmd_set_profile(client, &name, respond_to.as_deref(), &channels).await,
}
}

/// Publish this identity's kind:10100 agent profile — sign and submit a
/// replaceable event whose content carries the directory display name, the
/// respond-to policy, and the channels the agent is a member of.
///
/// Clients discover relay agents by querying kind:10100 (see the desktop's
/// `list_relay_agents`); mention eligibility additionally requires the
/// profile to declare `respond_to` and a channel shared with the viewer.
/// An agent that never publishes a profile is invisible to agent
/// directories on machines other than its host.
async fn cmd_set_profile(
client: &BuzzClient,
name: &str,
respond_to: Option<&str>,
channels: &[String],
) -> Result<(), CliError> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(CliError::Usage("--name must not be empty".into()));
}
if let Some(policy) = respond_to {
match policy {
"anyone" | "owner-only" | "allowlist" => {}
_ => {
return Err(CliError::Usage(format!(
"--respond-to must be 'anyone', 'owner-only', or 'allowlist' (got: {policy})"
)))
}
}
}
for channel in channels {
parse_uuid_arg(channel)?;
}

let mut content = serde_json::Map::new();
content.insert("name".into(), json!(trimmed));
if let Some(policy) = respond_to {
content.insert("respond_to".into(), json!(policy));
}
if !channels.is_empty() {
content.insert("channel_ids".into(), json!(channels));
}
let content = serde_json::Value::Object(content).to_string();

use nostr::{EventBuilder, Kind};
let builder = EventBuilder::new(
Kind::Custom(buzz_sdk::kind::KIND_AGENT_PROFILE as u16),
&content,
)
.tags([]);
let event = client.sign_event(builder)?;

let response = client.submit_event(event).await?;
println!("{response}");
Ok(())
}

fn parse_uuid_arg(value: &str) -> Result<(), CliError> {
uuid::Uuid::parse_str(value)
.map(|_| ())
.map_err(|e| CliError::Usage(format!("invalid --channel UUID '{value}': {e}")))
}

/// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by
Expand Down
20 changes: 20 additions & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,26 @@ Examples:\n \
buzz agents archived"
)]
Archived,
/// Publish this agent's kind:10100 profile to the relay agent directory
#[command(
name = "set-profile",
after_help = "Signs and submits a kind:10100 agent profile event as the current \
identity. The profile is what agent directories (and clients on other \
machines) use to discover this agent by name.\n\n\
Examples:\n \
buzz agents set-profile --name Kiku"
)]
SetProfile {
/// Display name shown in agent directories
#[arg(long)]
name: String,
/// Who this agent responds to: anyone | owner-only | allowlist
#[arg(long)]
respond_to: Option<String>,
/// Channel UUID this agent is a member of (repeatable)
#[arg(long = "channel")]
channels: Vec<String>,
},
}

#[derive(Subcommand)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,40 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent
);
});

test("isAgentIdentityInManagedList: keeps directory-invocable agents missing from the local registry", () => {
const managedAgentPubkeys = new Set([PUB_A]);
const mentionableAgentPubkeys = new Set([PUB_B]);

// Hosted by another machine but invocable via its kind:10100 profile —
// must survive the gate or cross-device mentions are impossible.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
);
// Mentionable lookup is normalized like the managed lookup.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B.toUpperCase() },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
);
// Unknown to both the registry and the directory still hides.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_C },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
false,
);
});

test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
Expand Down
15 changes: 13 additions & 2 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,21 @@ export function getMentionableAgentPubkeys({
export function isAgentIdentityInManagedList(
candidate: { isAgent?: boolean; pubkey: string },
managedAgentPubkeys: ReadonlySet<string>,
mentionableAgentPubkeys: ReadonlySet<string> = new Set(),
) {
if (candidate.isAgent !== true) {
return true;
}
const normalized = normalizePubkey(candidate.pubkey);
// An agent that is invocable via the relay directory (kind:10100 profile
// with respond_to + a shared channel) is a valid mention target even when
// this machine's local registry has no record of it — the agent may be
// hosted by another desktop. Without this, cross-device agent mentions
// are impossible: the local-registry gate vetoes every remote agent
// before eligibility is ever consulted.
return (
candidate.isAgent !== true ||
managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
managedAgentPubkeys.has(normalized) ||
mentionableAgentPubkeys.has(normalized)
);
}

Expand Down
102 changes: 102 additions & 0 deletions desktop/src/features/messages/lib/liveAgentMentionFallback.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
resolveLivePersonaMentionPubkey,
shouldSkipLocalStartForOnlineAgent,
} from "./liveAgentMentionFallback.ts";

const KIKU_LIVE = "a".repeat(64);
const KIKU_ORPHAN = "b".repeat(64);
const CRAWFORD = "c".repeat(64);

// ── resolveLivePersonaMentionPubkey ───────────────────────────────────

test("resolves a live agent whose name matches the persona mention", () => {
const pubkey = resolveLivePersonaMentionPubkey(
[{ name: "Kiku", pubkey: KIKU_LIVE }],
"Kiku",
new Set(),
);
assert.equal(pubkey, KIKU_LIVE);
});

test("name matching is case- and whitespace-insensitive", () => {
const pubkey = resolveLivePersonaMentionPubkey(
[{ name: " kiku ", pubkey: KIKU_LIVE }],
"KIKU",
new Set(),
);
assert.equal(pubkey, KIKU_LIVE);
});

test("prefers the match that is a member of the current channel", () => {
const pubkey = resolveLivePersonaMentionPubkey(
[
{ name: "Kiku", pubkey: KIKU_ORPHAN },
{ name: "Kiku", pubkey: KIKU_LIVE },
],
"Kiku",
new Set([KIKU_LIVE]),
);
assert.equal(pubkey, KIKU_LIVE);
});

test("falls back to the first match when no match is a channel member", () => {
const pubkey = resolveLivePersonaMentionPubkey(
[
{ name: "Kiku", pubkey: KIKU_ORPHAN },
{ name: "Kiku", pubkey: KIKU_LIVE },
],
"Kiku",
new Set([CRAWFORD]),
);
assert.equal(pubkey, KIKU_ORPHAN);
});

test("returns normalized pubkeys", () => {
const pubkey = resolveLivePersonaMentionPubkey(
[{ name: "Kiku", pubkey: ` ${KIKU_LIVE.toUpperCase()} ` }],
"Kiku",
new Set(),
);
assert.equal(pubkey, KIKU_LIVE);
});

test("returns null when no live agent matches the name", () => {
const pubkey = resolveLivePersonaMentionPubkey(
[{ name: "Crawford", pubkey: CRAWFORD }],
"Kiku",
new Set(),
);
assert.equal(pubkey, null);
});

test("returns null for an empty or missing directory", () => {
assert.equal(resolveLivePersonaMentionPubkey([], "Kiku", new Set()), null);
assert.equal(
resolveLivePersonaMentionPubkey(undefined, "Kiku", new Set()),
null,
);
});

test("returns null for a blank display name", () => {
const pubkey = resolveLivePersonaMentionPubkey(
[{ name: "Kiku", pubkey: KIKU_LIVE }],
" ",
new Set(),
);
assert.equal(pubkey, null);
});

// ── shouldSkipLocalStartForOnlineAgent ────────────────────────────────

test("skips the local start when the agent is online on the relay", () => {
assert.equal(shouldSkipLocalStartForOnlineAgent("online"), true);
});

test("does not skip for away, offline, or unknown presence", () => {
assert.equal(shouldSkipLocalStartForOnlineAgent("away"), false);
assert.equal(shouldSkipLocalStartForOnlineAgent("offline"), false);
assert.equal(shouldSkipLocalStartForOnlineAgent(undefined), false);
});
52 changes: 52 additions & 0 deletions desktop/src/features/messages/lib/liveAgentMentionFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { normalizePubkey } from "@/shared/lib/pubkey";

/** Minimal shape of a relay-directory agent needed for mention fallback. */
export type LiveRelayAgentRef = {
name: string;
pubkey: string;
};

/**
* Resolve a persona mention to an already-live relay agent with the same
* display name, if one exists.
*
* Persona mentions normally instantiate a local agent. On a desktop that is
* not the agent's host machine that spawn fails (harness definitions are
* per-machine), the send is blocked, and each attempt mints an orphan
* keypair. When an agent with the persona's name is already registered on
* the relay, the mention should tag that agent instead. Prefers an agent
* that is already a member of the current channel; falls back to the first
* name match.
*
* Returns the normalized pubkey to mention, or null when no live agent
* matches (the caller proceeds with persona instantiation).
*/
export function resolveLivePersonaMentionPubkey(
relayAgents: readonly LiveRelayAgentRef[] | undefined,
displayName: string,
memberPubkeys: ReadonlySet<string>,
): string | null {
const needle = displayName.trim().toLowerCase();
if (!needle) {
return null;
}
const matches = (relayAgents ?? [])
.filter((agent) => agent.name.trim().toLowerCase() === needle)
.map((agent) => normalizePubkey(agent.pubkey));
if (matches.length === 0) {
return null;
}
return matches.find((pubkey) => memberPubkeys.has(pubkey)) ?? matches[0];
}

/**
* True when a managed-agent mention should skip the local start attempt:
* the agent is already online on the relay, so it is running somewhere —
* possibly hosted by another machine whose runtime this desktop cannot
* (and must not) duplicate. Tag it without touching the local runtime.
*/
export function shouldSkipLocalStartForOnlineAgent(
presenceStatus: string | undefined,
): boolean {
return presenceStatus === "online";
}
14 changes: 14 additions & 0 deletions desktop/src/features/messages/lib/searchUserLabels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { UserSearchResult } from "@/shared/api/types";

export function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}

export function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return null;
}
24 changes: 11 additions & 13 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ import type {
AgentPersona,
ChannelMember,
ChannelType,
UserSearchResult,
} from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
formatSearchUserDisplayName,
formatSearchUserSecondaryLabel,
} from "./searchUserLabels";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
Expand All @@ -56,17 +59,6 @@ export type PersonaMentionTarget = {
type UseMentionsOptions = {
channelType?: ChannelType | null;
};
function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}
function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return null;
}
function appendUniqueName(current: string[], name: string): string[] {
return current.some(
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
Expand Down Expand Up @@ -246,7 +238,13 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
if (
!isAgentIdentityInManagedList(
candidate,
managedAgentPubkeys,
mentionableAgentPubkeys,
)
) {
return;
}
if (
Expand Down
Loading