Publish agent catalog entries to relay

This commit is contained in:
kenny lopez
2026-07-23 15:27:03 -07:00
parent 453dd7a35a
commit 2b8f1d790f
18 changed files with 1921 additions and 654 deletions
+17
View File
@@ -182,6 +182,21 @@ pub const KIND_TEAM: u32 = 30176;
/// since these events are world-readable on the relay.
pub const KIND_MANAGED_AGENT: u32 = 30177;
/// Buzz community agent catalog entry (parameterized replaceable, owner-authored).
///
/// Addressed by `(pubkey, kind, d_tag)` where `d_tag` is the source persona's
/// stable local id. Unlike [`KIND_PERSONA`], readers intentionally query this
/// kind across every community member: a `published` head contains an explicit
/// public projection plus a verified same-relay agent-snapshot reference, while
/// an `unpublished` head removes that coordinate from discovery.
///
/// Privacy contract: catalog entries and their referenced snapshots are
/// community-readable plaintext. Publishers MUST use an explicit allowlist
/// projection that excludes private keys, auth tags, environment variables,
/// machine-local runtime state, and response allowlists. Optional memory is
/// included only at the user-selected `none`, `core`, or `everything` level.
pub const KIND_PERSONA_CATALOG: u32 = 30178;
// NIP-56 reporting
/// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984).
///
@@ -510,6 +525,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_PERSONA,
KIND_TEAM,
KIND_MANAGED_AGENT,
KIND_PERSONA_CATALOG,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
@@ -708,6 +724,7 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–
const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA_CATALOG)); // 30178 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
+355 -4
View File
@@ -27,9 +27,9 @@ use buzz_core::kind::{
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PERSONA_CATALOG, KIND_PIN_LIST,
KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE,
KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS,
KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
@@ -200,7 +200,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_PROFILE => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_PERSONA_CATALOG | KIND_TEAM | KIND_MANAGED_AGENT
| super::push_lease::KIND_PUSH_LEASE => {
Ok(Scope::UsersWrite)
}
@@ -406,6 +406,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_AGENT_PROFILE
// NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag).
| KIND_PERSONA
// Community catalog publications (30178): owner-authored, but read
// across all members and keyed by the source persona's stable id.
| KIND_PERSONA_CATALOG
// NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored,
// keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them.
| KIND_TEAM
@@ -1066,6 +1069,254 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> {
Ok(())
}
/// Validate the public envelope and plaintext projection of a community catalog
/// entry before it can replace the current kind:30178 head.
///
/// The relay deliberately rejects unknown fields here. This is stricter than
/// the private owner projection (`kind:30175`) because every catalog entry and
/// referenced snapshot is readable by the community. The explicit allowlist
/// prevents accidental additions such as env vars, auth tags, or response
/// allowlists from silently becoming public metadata.
fn validate_persona_catalog_envelope(event: &Event) -> Result<(), String> {
use serde_json::{Map, Value};
const MAX_SOURCE_ID_LEN: usize = 128;
const MAX_SOURCE_UPDATED_AT_LEN: usize = 64;
const MAX_AGENT_SNAPSHOT_JSON_BYTES: u64 = 5 * 1024 * 1024;
fn one_tag<'a>(event: &'a Event, name: &str) -> Result<&'a str, String> {
let values: Vec<&str> = event
.tags
.iter()
.filter_map(|tag| {
let parts = tag.as_slice();
(parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].as_str())
})
.collect();
if values.len() != 1 {
return Err(format!(
"persona catalog event must have exactly one `{name}` tag (got {})",
values.len()
));
}
Ok(values[0])
}
fn optional_one_tag<'a>(event: &'a Event, name: &str) -> Result<Option<&'a str>, String> {
let values: Vec<&str> = event
.tags
.iter()
.filter_map(|tag| {
let parts = tag.as_slice();
(parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].as_str())
})
.collect();
if values.len() > 1 {
return Err(format!(
"persona catalog event must have at most one `{name}` tag (got {})",
values.len()
));
}
Ok(values.first().copied())
}
fn object<'a>(value: &'a Value, label: &str) -> Result<&'a Map<String, Value>, String> {
value
.as_object()
.ok_or_else(|| format!("persona catalog `{label}` must be an object"))
}
fn reject_unknown(
object: &Map<String, Value>,
allowed: &[&str],
label: &str,
) -> Result<(), String> {
if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
return Err(format!(
"persona catalog `{label}` contains unsupported field `{key}`"
));
}
Ok(())
}
fn required_string<'a>(
object: &'a Map<String, Value>,
key: &str,
label: &str,
) -> Result<&'a str, String> {
object
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("persona catalog `{label}.{key}` must be a non-empty string"))
}
fn optional_string_or_null(
object: &Map<String, Value>,
key: &str,
label: &str,
) -> Result<(), String> {
if object
.get(key)
.is_some_and(|value| !value.is_null() && !value.is_string())
{
return Err(format!(
"persona catalog `{label}.{key}` must be a string or null"
));
}
Ok(())
}
let source_id = one_tag(event, "d")?;
if source_id.is_empty()
|| source_id.len() > MAX_SOURCE_ID_LEN
|| source_id.chars().any(char::is_control)
{
return Err(format!(
"persona catalog `d` tag must be 1-{MAX_SOURCE_ID_LEN} non-control characters"
));
}
let status = one_tag(event, "status")?;
if status != "published" && status != "unpublished" {
return Err("persona catalog `status` must be `published` or `unpublished`".to_string());
}
let source_updated_at = one_tag(event, "source_updated_at")?;
if source_updated_at.is_empty() || source_updated_at.len() > MAX_SOURCE_UPDATED_AT_LEN {
return Err(format!(
"persona catalog `source_updated_at` must be 1-{MAX_SOURCE_UPDATED_AT_LEN} characters"
));
}
let memory_tag = optional_one_tag(event, "memory")?;
let parsed: Value = serde_json::from_str(&event.content)
.map_err(|_| "persona catalog content must be valid JSON".to_string())?;
let root = object(&parsed, "content")?;
if root.get("format").and_then(Value::as_str) != Some("buzz-persona-catalog")
|| root.get("version").and_then(Value::as_u64) != Some(1)
|| root.get("status").and_then(Value::as_str) != Some(status)
|| root.get("sourcePersonaId").and_then(Value::as_str) != Some(source_id)
|| root.get("sourceUpdatedAt").and_then(Value::as_str) != Some(source_updated_at)
{
return Err("persona catalog content does not match its signed envelope".to_string());
}
if status == "unpublished" {
reject_unknown(
root,
&[
"format",
"version",
"status",
"sourcePersonaId",
"sourceUpdatedAt",
],
"content",
)?;
if memory_tag.is_some() {
return Err(
"unpublished persona catalog events must not carry a `memory` tag".to_string(),
);
}
return Ok(());
}
reject_unknown(
root,
&[
"format",
"version",
"status",
"sourcePersonaId",
"sourceUpdatedAt",
"memoryLevel",
"agent",
"snapshot",
],
"content",
)?;
let memory = memory_tag.ok_or_else(|| {
"published persona catalog events must carry exactly one `memory` tag".to_string()
})?;
if !matches!(memory, "none" | "core" | "everything")
|| root.get("memoryLevel").and_then(Value::as_str) != Some(memory)
{
return Err(
"persona catalog memory level must be `none`, `core`, or `everything` and match content"
.to_string(),
);
}
let agent = object(
root.get("agent")
.ok_or_else(|| "persona catalog content is missing `agent`".to_string())?,
"agent",
)?;
reject_unknown(
agent,
&[
"displayName",
"avatarUrl",
"systemPrompt",
"runtime",
"model",
"provider",
],
"agent",
)?;
required_string(agent, "displayName", "agent")?;
if !agent.get("systemPrompt").is_some_and(Value::is_string) {
return Err("persona catalog `agent.systemPrompt` must be a string".to_string());
}
for key in ["avatarUrl", "runtime", "model", "provider"] {
optional_string_or_null(agent, key, "agent")?;
}
let snapshot = object(
root.get("snapshot")
.ok_or_else(|| "persona catalog content is missing `snapshot`".to_string())?,
"snapshot",
)?;
reject_unknown(
snapshot,
&["url", "sha256", "size", "type", "fileName"],
"snapshot",
)?;
required_string(snapshot, "url", "snapshot")?;
let sha256 = required_string(snapshot, "sha256", "snapshot")?;
if sha256.len() != 64
|| !sha256
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
{
return Err("persona catalog `snapshot.sha256` must be 64 lowercase hex chars".to_string());
}
snapshot
.get("size")
.and_then(Value::as_u64)
.filter(|size| (1..=MAX_AGENT_SNAPSHOT_JSON_BYTES).contains(size))
.ok_or_else(|| {
"persona catalog `snapshot.size` must be within the 5 MiB agent JSON limit".to_string()
})?;
if snapshot.get("type").and_then(Value::as_str) != Some("application/json") {
return Err("persona catalog snapshot must use `application/json`".to_string());
}
let filename = required_string(snapshot, "fileName", "snapshot")?;
if !filename.to_ascii_lowercase().ends_with(".agent.json")
|| filename.contains('/')
|| filename.contains('\\')
{
return Err(
"persona catalog `snapshot.fileName` must be a plain `.agent.json` filename"
.to_string(),
);
}
Ok(())
}
/// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext.
///
/// Checks:
@@ -2025,6 +2276,11 @@ async fn ingest_event_inner(
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
if kind_u32 == KIND_PERSONA_CATALOG {
validate_persona_catalog_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
// Track pre-created channel UUID for compensation on insert failure.
let mut pre_created_channel: Option<Uuid> = None;
@@ -2808,6 +3064,7 @@ mod tests {
KIND_AGENT_ENGRAM,
KIND_AGENT_PROFILE,
KIND_PERSONA,
KIND_PERSONA_CATALOG,
KIND_TEAM,
KIND_MANAGED_AGENT,
KIND_AGENT_TURN_METRIC,
@@ -2893,6 +3150,17 @@ mod tests {
assert!(!requires_h_channel_scope(KIND_PERSONA));
}
#[test]
fn persona_catalog_is_in_scope_allowlist_and_global_only() {
let dummy = make_dummy_event();
assert_eq!(
required_scope_for_kind(KIND_PERSONA_CATALOG, &dummy).unwrap(),
Scope::UsersWrite,
);
assert!(is_global_only_kind(KIND_PERSONA_CATALOG));
assert!(!requires_h_channel_scope(KIND_PERSONA_CATALOG));
}
#[test]
fn team_and_managed_agent_are_in_scope_allowlist() {
let dummy = make_dummy_event();
@@ -3534,6 +3802,89 @@ mod tests {
assert!(err.contains("`d` tag"), "got: {err}");
}
fn make_persona_catalog(status: &str, memory: Option<&str>, content: &str) -> Event {
let mut tags = vec![
vec!["d", "persona-1"],
vec!["status", status],
vec!["source_updated_at", "2026-07-23T00:00:00Z"],
];
if let Some(memory) = memory {
tags.push(vec!["memory", memory]);
}
let tag_refs: Vec<Vec<&str>> = tags;
let borrowed: Vec<&[&str]> = tag_refs.iter().map(Vec::as_slice).collect();
make_event_with_tags(KIND_PERSONA_CATALOG, content, &borrowed)
}
fn valid_published_catalog_content() -> String {
serde_json::json!({
"format": "buzz-persona-catalog",
"version": 1,
"status": "published",
"sourcePersonaId": "persona-1",
"sourceUpdatedAt": "2026-07-23T00:00:00Z",
"memoryLevel": "none",
"agent": {
"displayName": "Reviewer",
"avatarUrl": null,
"systemPrompt": "Review changes.",
"runtime": "goose",
"model": "claude",
"provider": null
},
"snapshot": {
"url": "https://relay.example/media/snapshot",
"sha256": "a".repeat(64),
"size": 512,
"type": "application/json",
"fileName": "reviewer.agent.json"
}
})
.to_string()
}
#[test]
fn persona_catalog_accepts_public_allowlist_projection() {
let content = valid_published_catalog_content();
let event = make_persona_catalog("published", Some("none"), &content);
assert!(validate_persona_catalog_envelope(&event).is_ok());
}
#[test]
fn persona_catalog_rejects_secret_or_allowlist_fields() {
let mut content: serde_json::Value =
serde_json::from_str(&valid_published_catalog_content()).unwrap();
content["agent"]["envVars"] = serde_json::json!({"ANTHROPIC_API_KEY": "secret"});
let event = make_persona_catalog("published", Some("none"), &content.to_string());
let error = validate_persona_catalog_envelope(&event).unwrap_err();
assert!(
error.contains("unsupported field `envVars`"),
"got: {error}"
);
}
#[test]
fn persona_catalog_accepts_unpublished_replacement() {
let content = serde_json::json!({
"format": "buzz-persona-catalog",
"version": 1,
"status": "unpublished",
"sourcePersonaId": "persona-1",
"sourceUpdatedAt": "2026-07-23T00:00:00Z"
})
.to_string();
let event = make_persona_catalog("unpublished", None, &content);
assert!(validate_persona_catalog_envelope(&event).is_ok());
}
#[test]
fn persona_catalog_rejects_memory_mismatch() {
let content = valid_published_catalog_content();
let event = make_persona_catalog("published", Some("everything"), &content);
let error = validate_persona_catalog_envelope(&event).unwrap_err();
assert!(error.contains("memory level"), "got: {error}");
}
// ─── agent_turn_metric envelope tests ────────────────────────────────────
/// Build an event for kind:44200 with the given tags and content.
@@ -2,11 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
getCatalogPersonas,
getCatalogSelectionState,
getLibraryPersonas,
getPersonaLabelsById,
getPersonaLibraryState,
isCatalogPersonaSelected,
} from "./catalog.ts";
@@ -25,66 +22,6 @@ function createPersona(id, displayName, overrides = {}) {
};
}
test("getCatalogPersonas hides built-ins and includes shared custom agents", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }),
createPersona("custom:builder", "Builder"),
];
assert.deepEqual(
getCatalogPersonas(personas, new Set(["custom:builder"])).map(
(persona) => persona.id,
),
["custom:builder"],
);
});
test("getCatalogSelectionState only selects shared custom agents", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
createPersona("custom:builder", "Builder"),
];
const state = getCatalogSelectionState(
personas,
new Set(["builtin:fizz", "custom:builder"]),
);
assert.deepEqual(
state.catalogPersonas.map((persona) => persona.id),
["custom:builder"],
);
assert.deepEqual(
state.selectedCatalogPersonas.map((persona) => persona.id),
["custom:builder"],
);
assert.deepEqual(
state.unselectedCatalogPersonas.map((persona) => persona.id),
[],
);
});
test("getCatalogPersonas keeps chooser order stable when selection changes", () => {
const inactive = [
createPersona("custom:fizz", "Fizz", { isActive: false }),
createPersona("custom:reviewer", "Reviewer", {
isActive: true,
}),
];
const active = [
createPersona("custom:fizz", "Fizz", { isActive: true }),
createPersona("custom:reviewer", "Reviewer", {
isActive: false,
}),
];
const shared = new Set(["custom:fizz", "custom:reviewer"]);
assert.deepEqual(
getCatalogPersonas(inactive, shared).map((persona) => persona.id),
getCatalogPersonas(active, shared).map((persona) => persona.id),
);
});
test("isCatalogPersonaSelected treats active catalog personas as selected", () => {
assert.equal(
isCatalogPersonaSelected(
@@ -122,28 +59,6 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => {
});
});
test("getPersonaLibraryState keeps built-ins in the library but not the catalog", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
createPersona("custom:builder", "Builder"),
];
const state = getPersonaLibraryState(
personas,
new Set(["builtin:fizz", "custom:builder"]),
);
assert.deepEqual(
state.libraryPersonas.map((persona) => persona.id),
["builtin:fizz", "custom:builder"],
);
assert.deepEqual(
state.catalogPersonas.map((persona) => persona.id),
["custom:builder"],
);
assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz");
});
test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => {
const avatarUrl = "https://example.test/coordinator.png";
const personas = [
@@ -1,17 +1,5 @@
import type { AgentPersona } from "@/shared/api/types";
export type CatalogSelectionState = {
catalogPersonas: AgentPersona[];
selectedCatalogPersonas: AgentPersona[];
unselectedCatalogPersonas: AgentPersona[];
};
export type PersonaLibraryState = {
catalogPersonas: AgentPersona[];
libraryPersonas: AgentPersona[];
personaLabelsById: Record<string, string>;
};
export function isPersonaActive(persona: AgentPersona) {
return persona.isActive;
}
@@ -24,62 +12,12 @@ export function getLibraryPersonas(personas: readonly AgentPersona[]) {
return getActivePersonas(personas);
}
export function isPersonaVisibleInCatalog(
persona: AgentPersona,
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
) {
return !persona.isBuiltIn && sharedCatalogPersonaIds.has(persona.id);
}
export function getCatalogPersonas(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
) {
return personas
.filter((persona) =>
isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds),
)
.sort((left, right) => left.displayName.localeCompare(right.displayName));
}
export function isCatalogPersonaSelected(persona: AgentPersona) {
return persona.isActive;
}
export function getCatalogSelectionState(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
): CatalogSelectionState {
const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds);
return {
catalogPersonas,
selectedCatalogPersonas: catalogPersonas.filter(isCatalogPersonaSelected),
unselectedCatalogPersonas: catalogPersonas.filter(
(persona) => !isCatalogPersonaSelected(persona),
),
};
}
export function getPersonaLabelsById(personas: readonly AgentPersona[]) {
return Object.fromEntries(
personas.map((persona) => [persona.id, persona.displayName]),
);
}
export function getPersonaLibraryState(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
): PersonaLibraryState {
const libraryPersonas = getLibraryPersonas(personas);
const { catalogPersonas } = getCatalogSelectionState(
personas,
sharedCatalogPersonaIds,
);
return {
catalogPersonas,
libraryPersonas,
personaLabelsById: getPersonaLabelsById(personas),
};
}
@@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
catalogPersonasFromPublications,
catalogPublicationsFromEvents,
sanitizeCatalogSnapshotBytes,
} from "./personaCatalogRelay.ts";
const ALICE = "a".repeat(64);
const BOB = "b".repeat(64);
const SNAPSHOT = {
url: `https://relay.example/media/${"c".repeat(64)}`,
sha256: "c".repeat(64),
size: 512,
type: "application/json",
fileName: "reviewer.agent.json",
};
function catalogEvent({
createdAt,
id,
owner = ALICE,
sourcePersonaId = "reviewer",
status = "published",
memoryLevel = "none",
}) {
const sourceUpdatedAt = `2026-07-23T00:00:0${createdAt}.000Z`;
const content =
status === "published"
? {
format: "buzz-persona-catalog",
version: 1,
status,
sourcePersonaId,
sourceUpdatedAt,
memoryLevel,
agent: {
displayName: "Relay Reviewer",
avatarUrl: null,
systemPrompt: "Review changes.",
runtime: "goose",
model: "claude",
provider: null,
},
snapshot: SNAPSHOT,
}
: {
format: "buzz-persona-catalog",
version: 1,
status,
sourcePersonaId,
sourceUpdatedAt,
};
return {
id,
pubkey: owner,
created_at: createdAt,
kind: 30178,
tags: [
["d", sourcePersonaId],
["status", status],
["source_updated_at", sourceUpdatedAt],
...(status === "published" ? [["memory", memoryLevel]] : []),
],
content: JSON.stringify(content),
sig: "sig",
};
}
test("a catalog publication from Alice is discoverable by Bob", () => {
const publications = catalogPublicationsFromEvents([
catalogEvent({ createdAt: 1, id: "alice-reviewer" }),
]);
const personas = catalogPersonasFromPublications(publications, [], BOB);
assert.equal(personas.length, 1);
assert.equal(personas[0].displayName, "Relay Reviewer");
assert.equal(personas[0].isActive, false);
assert.equal(personas[0].catalogSource.ownerPubkey, ALICE);
assert.equal(personas[0].catalogSource.isOwn, false);
assert.deepEqual(personas[0].catalogSource.snapshot, SNAPSHOT);
});
test("the newest unpublished head removes an older publication from discovery", () => {
const publications = catalogPublicationsFromEvents([
catalogEvent({ createdAt: 1, id: "published" }),
catalogEvent({ createdAt: 2, id: "unpublished", status: "unpublished" }),
]);
assert.equal(publications.length, 1);
assert.equal(publications[0].status, "unpublished");
assert.deepEqual(catalogPersonasFromPublications(publications, [], BOB), []);
});
test("catalog coordinates remain independent across authors", () => {
const publications = catalogPublicationsFromEvents([
catalogEvent({ createdAt: 1, id: "alice", owner: ALICE }),
catalogEvent({ createdAt: 1, id: "bob", owner: BOB }),
]);
assert.equal(publications.length, 2);
assert.equal(
catalogPersonasFromPublications(publications, [], BOB).length,
2,
);
});
test("catalog snapshot sanitization strips secrets and response allowlists", () => {
const source = {
format: "buzz-agent-snapshot",
version: 1,
definition: {
name: "Reviewer",
systemPrompt: "Review changes.",
runtime: "goose",
model: "claude",
provider: "anthropic",
respondTo: "allowlist",
respondToAllowlist: [BOB],
namePool: ["Reviewer"],
envVars: { ANTHROPIC_API_KEY: "secret" },
privateKeyNsec: "nsec-secret",
authTag: "auth-secret",
},
profile: { displayName: "Reviewer" },
memory: { level: "none", entries: [] },
relayUrl: "wss://private.example",
};
const sanitized = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(
sanitizeCatalogSnapshotBytes(
Array.from(new TextEncoder().encode(JSON.stringify(source))),
"none",
),
),
),
);
assert.equal(sanitized.definition.systemPrompt, "Review changes.");
assert.equal(sanitized.definition.respondTo, "allowlist");
assert.equal("respondToAllowlist" in sanitized.definition, false);
assert.equal("envVars" in sanitized.definition, false);
assert.equal("privateKeyNsec" in sanitized.definition, false);
assert.equal("authTag" in sanitized.definition, false);
assert.equal("relayUrl" in sanitized, false);
});
test("selected core memory survives the public allowlist projection", () => {
const source = {
format: "buzz-agent-snapshot",
version: 1,
definition: { name: "Reviewer", systemPrompt: "Review changes." },
profile: { displayName: "Reviewer" },
memory: {
level: "core",
entries: [{ slug: "core", body: "Prefers concise findings." }],
},
};
const sanitized = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(
sanitizeCatalogSnapshotBytes(
Array.from(new TextEncoder().encode(JSON.stringify(source))),
"core",
),
),
),
);
assert.deepEqual(sanitized.memory, source.memory);
});
test("catalog snapshot sanitization rejects memory beyond the selected level", () => {
const source = {
format: "buzz-agent-snapshot",
version: 1,
definition: { name: "Reviewer", systemPrompt: "Review changes." },
profile: { displayName: "Reviewer" },
memory: {
level: "core",
entries: [
{ slug: "core", body: "Public core instructions." },
{ slug: "mem/private", body: "Must not escape a core-only share." },
],
},
};
assert.throws(
() =>
sanitizeCatalogSnapshotBytes(
Array.from(new TextEncoder().encode(JSON.stringify(source))),
"core",
),
/outside the selected sharing level/u,
);
});
@@ -0,0 +1,618 @@
import { relayClient } from "@/shared/api/relayClient";
import { signRelayEvent, uploadMediaBytes } from "@/shared/api/tauri";
import {
encodeAgentSnapshotForSend,
type SnapshotMemoryLevel,
} from "@/shared/api/tauriPersonas";
import type {
AgentPersona,
ManagedAgent,
RelayEvent,
} from "@/shared/api/types";
import { KIND_PERSONA_CATALOG } from "@/shared/constants/kinds";
const CATALOG_FORMAT = "buzz-persona-catalog";
const CATALOG_VERSION = 1;
const MAX_CATALOG_EVENTS = 1_000;
const MAX_SNAPSHOT_JSON_BYTES = 5 * 1024 * 1024;
type CatalogStatus = "published" | "unpublished";
export type CatalogSnapshotReference = {
url: string;
sha256: string;
size: number;
type: "application/json";
fileName: string;
};
export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel;
type CatalogAgentProjection = {
displayName: string;
avatarUrl: string | null;
systemPrompt: string;
runtime: string | null;
model: string | null;
provider: string | null;
};
type PublishedCatalogContent = {
format: typeof CATALOG_FORMAT;
version: typeof CATALOG_VERSION;
status: "published";
sourcePersonaId: string;
sourceUpdatedAt: string;
memoryLevel: SnapshotMemoryLevel;
agent: CatalogAgentProjection;
snapshot: CatalogSnapshotReference;
};
type UnpublishedCatalogContent = {
format: typeof CATALOG_FORMAT;
version: typeof CATALOG_VERSION;
status: "unpublished";
sourcePersonaId: string;
sourceUpdatedAt: string;
};
type CatalogContent = PublishedCatalogContent | UnpublishedCatalogContent;
export type PersonaCatalogPublication = {
eventId: string;
ownerPubkey: string;
sourcePersonaId: string;
sourceUpdatedAt: string;
createdAt: number;
status: CatalogStatus;
memoryLevel: SnapshotMemoryLevel | null;
agent: CatalogAgentProjection | null;
snapshot: CatalogSnapshotReference | null;
};
export type CatalogPersona = AgentPersona & {
catalogSource: {
eventId: string;
ownerPubkey: string;
isOwn: boolean;
sourcePersonaId: string;
sourceUpdatedAt: string;
memoryLevel: SnapshotMemoryLevel;
snapshot: CatalogSnapshotReference;
};
};
type JsonObject = Record<string, unknown>;
function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringOrNull(value: unknown): string | null | undefined {
return typeof value === "string" || value === null ? value : undefined;
}
function extractTag(event: RelayEvent, name: string): string | null {
const matches = event.tags.filter(
(tag) => tag[0] === name && typeof tag[1] === "string",
);
return matches.length === 1 ? (matches[0]?.[1] ?? null) : null;
}
function isMemoryLevel(value: unknown): value is SnapshotMemoryLevel {
return value === "none" || value === "core" || value === "everything";
}
function isSafeSnapshotReference(
value: unknown,
): value is CatalogSnapshotReference {
if (!isObject(value)) return false;
const fileName = value.fileName;
const sha256 = value.sha256;
const size = value.size;
const url = value.url;
return (
typeof url === "string" &&
url.length > 0 &&
!/[\s()]/u.test(url) &&
(() => {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
})() &&
typeof sha256 === "string" &&
/^[0-9a-f]{64}$/u.test(sha256) &&
typeof size === "number" &&
Number.isSafeInteger(size) &&
size > 0 &&
size <= MAX_SNAPSHOT_JSON_BYTES &&
value.type === "application/json" &&
typeof fileName === "string" &&
fileName.toLowerCase().endsWith(".agent.json") &&
!fileName.includes("/") &&
!fileName.includes("\\")
);
}
function parseCatalogContent(event: RelayEvent): CatalogContent | null {
let parsed: unknown;
try {
parsed = JSON.parse(event.content);
} catch {
return null;
}
if (!isObject(parsed)) return null;
const sourcePersonaId = extractTag(event, "d");
const status = extractTag(event, "status");
const sourceUpdatedAt = extractTag(event, "source_updated_at");
if (
event.kind !== KIND_PERSONA_CATALOG ||
parsed.format !== CATALOG_FORMAT ||
parsed.version !== CATALOG_VERSION ||
parsed.status !== status ||
parsed.sourcePersonaId !== sourcePersonaId ||
parsed.sourceUpdatedAt !== sourceUpdatedAt ||
!sourcePersonaId ||
!sourceUpdatedAt ||
(status !== "published" && status !== "unpublished")
) {
return null;
}
if (status === "unpublished") {
return {
format: CATALOG_FORMAT,
version: CATALOG_VERSION,
status,
sourcePersonaId,
sourceUpdatedAt,
};
}
const memoryLevel = extractTag(event, "memory");
if (
!isMemoryLevel(memoryLevel) ||
parsed.memoryLevel !== memoryLevel ||
!isObject(parsed.agent) ||
typeof parsed.agent.displayName !== "string" ||
parsed.agent.displayName.trim().length === 0 ||
typeof parsed.agent.systemPrompt !== "string" ||
stringOrNull(parsed.agent.avatarUrl) === undefined ||
stringOrNull(parsed.agent.runtime) === undefined ||
stringOrNull(parsed.agent.model) === undefined ||
stringOrNull(parsed.agent.provider) === undefined ||
!isSafeSnapshotReference(parsed.snapshot)
) {
return null;
}
return {
format: CATALOG_FORMAT,
version: CATALOG_VERSION,
status,
sourcePersonaId,
sourceUpdatedAt,
memoryLevel,
agent: {
displayName: parsed.agent.displayName,
avatarUrl: parsed.agent.avatarUrl as string | null,
systemPrompt: parsed.agent.systemPrompt,
runtime: parsed.agent.runtime as string | null,
model: parsed.agent.model as string | null,
provider: parsed.agent.provider as string | null,
},
snapshot: parsed.snapshot,
};
}
/**
* Collapse relay results to one latest head per `(author, sourcePersonaId)`.
* The relay already applies NIP-33 replacement, but doing this client-side
* makes discovery deterministic against older relays and test fixtures.
*/
export function catalogPublicationsFromEvents(
events: readonly RelayEvent[],
): PersonaCatalogPublication[] {
const sorted = [...events].sort(
(left, right) =>
right.created_at - left.created_at || right.id.localeCompare(left.id),
);
const seenCoordinates = new Set<string>();
const publications: PersonaCatalogPublication[] = [];
for (const event of sorted) {
const sourcePersonaId = extractTag(event, "d");
if (!sourcePersonaId) continue;
const coordinate = `${event.pubkey.toLowerCase()}:${sourcePersonaId}`;
if (seenCoordinates.has(coordinate)) continue;
seenCoordinates.add(coordinate);
const content = parseCatalogContent(event);
if (!content) continue;
publications.push({
eventId: event.id,
ownerPubkey: event.pubkey.toLowerCase(),
sourcePersonaId: content.sourcePersonaId,
sourceUpdatedAt: content.sourceUpdatedAt,
createdAt: event.created_at,
status: content.status,
memoryLevel: content.status === "published" ? content.memoryLevel : null,
agent: content.status === "published" ? content.agent : null,
snapshot: content.status === "published" ? content.snapshot : null,
});
}
return publications;
}
export async function fetchPersonaCatalogPublications(): Promise<
PersonaCatalogPublication[]
> {
const events = await relayClient.fetchEvents({
kinds: [KIND_PERSONA_CATALOG],
limit: MAX_CATALOG_EVENTS,
});
return catalogPublicationsFromEvents(events);
}
export function catalogPersonasFromPublications(
publications: readonly PersonaCatalogPublication[],
localPersonas: readonly AgentPersona[],
currentPubkey: string | null | undefined,
): CatalogPersona[] {
const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null;
return publications
.filter(
(
publication,
): publication is PersonaCatalogPublication & {
status: "published";
memoryLevel: SnapshotMemoryLevel;
agent: CatalogAgentProjection;
snapshot: CatalogSnapshotReference;
} =>
publication.status === "published" &&
publication.memoryLevel !== null &&
publication.agent !== null &&
publication.snapshot !== null,
)
.map((publication) => {
const ownLocalPersona =
publication.ownerPubkey === normalizedCurrentPubkey
? localPersonas.find(
(persona) => persona.id === publication.sourcePersonaId,
)
: undefined;
const persona: CatalogPersona = {
id:
publication.ownerPubkey === normalizedCurrentPubkey
? publication.sourcePersonaId
: `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`,
displayName: publication.agent.displayName,
avatarUrl: publication.agent.avatarUrl,
systemPrompt: publication.agent.systemPrompt,
runtime: publication.agent.runtime,
model: publication.agent.model,
provider: publication.agent.provider,
namePool: [],
isBuiltIn: false,
isActive: ownLocalPersona?.isActive ?? false,
sourceTeam: null,
envVars: {},
respondTo: null,
respondToAllowlist: [],
parallelism: null,
createdAt: publication.sourceUpdatedAt,
updatedAt: publication.sourceUpdatedAt,
catalogSource: {
eventId: publication.eventId,
ownerPubkey: publication.ownerPubkey,
isOwn: publication.ownerPubkey === normalizedCurrentPubkey,
sourcePersonaId: publication.sourcePersonaId,
sourceUpdatedAt: publication.sourceUpdatedAt,
memoryLevel: publication.memoryLevel,
snapshot: publication.snapshot,
},
};
return persona;
})
.sort((left, right) => left.displayName.localeCompare(right.displayName));
}
export function isCatalogPersona(
persona: AgentPersona,
): persona is CatalogPersona {
return "catalogSource" in persona && isObject(persona.catalogSource);
}
export function ownCatalogPublication(
publications: readonly PersonaCatalogPublication[],
ownerPubkey: string | null | undefined,
sourcePersonaId: string,
): PersonaCatalogPublication | null {
if (!ownerPubkey) return null;
const normalizedOwner = ownerPubkey.toLowerCase();
return (
publications.find(
(publication) =>
publication.ownerPubkey === normalizedOwner &&
publication.sourcePersonaId === sourcePersonaId,
) ?? null
);
}
function copyOptionalString(
source: JsonObject,
target: JsonObject,
key: string,
): void {
if (typeof source[key] === "string") target[key] = source[key];
}
function copyOptionalNumber(
source: JsonObject,
target: JsonObject,
key: string,
): void {
if (
typeof source[key] === "number" &&
Number.isFinite(source[key]) &&
source[key] >= 0
) {
target[key] = source[key];
}
}
/**
* Rebuild a catalog snapshot from a strict public allowlist.
*
* The normal portable snapshot already excludes identity keys, auth tags,
* environment variables, relay URLs, commands, and runtime state. Catalog
* publication applies a second boundary: response allowlists are also removed
* because they disclose source-community pubkeys and are not portable.
*/
export function sanitizeCatalogSnapshotBytes(
fileBytes: readonly number[],
expectedMemoryLevel: SnapshotMemoryLevel,
): number[] {
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(Uint8Array.from(fileBytes)));
} catch {
throw new Error("Couldn’t prepare this agent for the community catalog.");
}
if (
!isObject(parsed) ||
parsed.format !== "buzz-agent-snapshot" ||
parsed.version !== 1 ||
!isObject(parsed.definition) ||
!isObject(parsed.profile) ||
!isObject(parsed.memory) ||
parsed.memory.level !== expectedMemoryLevel ||
!Array.isArray(parsed.memory.entries)
) {
throw new Error("The generated agent snapshot is invalid.");
}
const definition: JsonObject = {};
copyOptionalString(parsed.definition, definition, "name");
if (typeof parsed.definition.sourceIsBuiltIn === "boolean") {
definition.sourceIsBuiltIn = parsed.definition.sourceIsBuiltIn;
}
for (const key of [
"systemPrompt",
"runtime",
"model",
"provider",
"respondTo",
]) {
copyOptionalString(parsed.definition, definition, key);
}
for (const key of [
"parallelism",
"idleTimeoutSeconds",
"maxTurnDurationSeconds",
]) {
copyOptionalNumber(parsed.definition, definition, key);
}
if (
Array.isArray(parsed.definition.namePool) &&
parsed.definition.namePool.every((value) => typeof value === "string")
) {
definition.namePool = [...parsed.definition.namePool];
}
const profile: JsonObject = {};
for (const key of ["displayName", "about", "avatarDataUrl", "avatarUrl"]) {
copyOptionalString(parsed.profile, profile, key);
}
if (
typeof profile.displayName !== "string" ||
profile.displayName.length === 0
) {
throw new Error("The generated agent snapshot has no display name.");
}
const entries = parsed.memory.entries.map((entry) => {
if (
!isObject(entry) ||
typeof entry.slug !== "string" ||
entry.slug.length === 0 ||
typeof entry.body !== "string"
) {
throw new Error("The generated agent snapshot has invalid memory.");
}
return { slug: entry.slug, body: entry.body };
});
const duplicateSlugs =
new Set(entries.map((entry) => entry.slug)).size !== entries.length;
const invalidMemorySelection =
duplicateSlugs ||
(expectedMemoryLevel === "none" && entries.length > 0) ||
(expectedMemoryLevel === "core" &&
(entries.length > 1 || entries.some((entry) => entry.slug !== "core"))) ||
(expectedMemoryLevel === "everything" &&
entries.some(
(entry) => entry.slug !== "core" && !entry.slug.startsWith("mem/"),
));
if (invalidMemorySelection) {
throw new Error(
"The generated agent snapshot includes memory outside the selected sharing level.",
);
}
const sanitized = {
format: "buzz-agent-snapshot",
version: 1,
definition,
profile,
memory: {
level: expectedMemoryLevel,
entries,
},
};
const bytes = Array.from(new TextEncoder().encode(JSON.stringify(sanitized)));
if (bytes.length > MAX_SNAPSHOT_JSON_BYTES) {
throw new Error(
"This agent is too large for the community catalog. Share less memory and try again.",
);
}
return bytes;
}
function publicAvatarUrl(avatarUrl: string | null): string | null {
if (!avatarUrl || avatarUrl.startsWith("data:") || avatarUrl.length > 2_048) {
return null;
}
return avatarUrl;
}
function monotonicCreatedAt(previousCreatedAt?: number | null): number {
return Math.max(Math.floor(Date.now() / 1_000), (previousCreatedAt ?? 0) + 1);
}
function requirePublishedCatalogEvent(
event: RelayEvent,
): PersonaCatalogPublication {
const publication = catalogPublicationsFromEvents([event])[0];
if (!publication) {
throw new Error("The relay returned an invalid catalog publication.");
}
return publication;
}
export async function publishPersonaToCatalog(input: {
persona: AgentPersona;
memoryLevel: SnapshotMemoryLevel;
linkedAgentPubkey: string | null;
previousCreatedAt?: number | null;
}): Promise<PersonaCatalogPublication> {
if (input.persona.isBuiltIn) {
throw new Error("Built-in agents can’t be published to the catalog.");
}
if (input.memoryLevel !== "none" && !input.linkedAgentPubkey) {
throw new Error("Start this agent before sharing its memory.");
}
const encoded = await encodeAgentSnapshotForSend(
input.persona.id,
input.memoryLevel,
"json",
input.linkedAgentPubkey,
);
const snapshotBytes = sanitizeCatalogSnapshotBytes(
encoded.fileBytes,
input.memoryLevel,
);
const descriptor = await uploadMediaBytes(snapshotBytes, encoded.fileName);
if (
!/^[0-9a-f]{64}$/u.test(descriptor.sha256) ||
descriptor.size !== snapshotBytes.length ||
!descriptor.url
) {
throw new Error("The relay returned an invalid catalog snapshot receipt.");
}
const content: PublishedCatalogContent = {
format: CATALOG_FORMAT,
version: CATALOG_VERSION,
status: "published",
sourcePersonaId: input.persona.id,
sourceUpdatedAt: input.persona.updatedAt,
memoryLevel: input.memoryLevel,
agent: {
displayName: input.persona.displayName,
avatarUrl: publicAvatarUrl(input.persona.avatarUrl),
systemPrompt: input.persona.systemPrompt,
runtime: input.persona.runtime,
model: input.persona.model,
provider: input.persona.provider,
},
snapshot: {
url: descriptor.url,
sha256: descriptor.sha256,
size: descriptor.size,
type: "application/json",
fileName: encoded.fileName,
},
};
const event = await signRelayEvent({
kind: KIND_PERSONA_CATALOG,
content: JSON.stringify(content),
createdAt: monotonicCreatedAt(input.previousCreatedAt),
tags: [
["d", input.persona.id],
["status", "published"],
["source_updated_at", input.persona.updatedAt],
["memory", input.memoryLevel],
],
});
const published = await relayClient.publishEvent(
event,
"Timed out publishing this agent to the catalog.",
"Failed to publish this agent to the catalog.",
);
return requirePublishedCatalogEvent(published);
}
export async function unpublishPersonaFromCatalog(input: {
persona: AgentPersona;
previousCreatedAt?: number | null;
}): Promise<PersonaCatalogPublication> {
const content: UnpublishedCatalogContent = {
format: CATALOG_FORMAT,
version: CATALOG_VERSION,
status: "unpublished",
sourcePersonaId: input.persona.id,
sourceUpdatedAt: input.persona.updatedAt,
};
const event = await signRelayEvent({
kind: KIND_PERSONA_CATALOG,
content: JSON.stringify(content),
createdAt: monotonicCreatedAt(input.previousCreatedAt),
tags: [
["d", input.persona.id],
["status", "unpublished"],
["source_updated_at", input.persona.updatedAt],
],
});
const published = await relayClient.publishEvent(
event,
"Timed out removing this agent from the catalog.",
"Failed to remove this agent from the catalog.",
);
return requirePublishedCatalogEvent(published);
}
export function linkedAgentPubkeyForPersona(
personaId: string,
managedAgents: readonly ManagedAgent[],
): string | null {
return (
managedAgents.find((agent) => agent.personaId === personaId)?.pubkey ?? null
);
}
@@ -1,153 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
readCatalogPersonaMemoryLevels,
readPublishedCatalogPersonaVersions,
readSharedCatalogPersonaIds,
writeCatalogPersonaMemoryLevels,
writePublishedCatalogPersonaVersions,
writeSharedCatalogPersonaIds,
} from "./personaCatalogVisibility.ts";
test("catalog visibility reads stored persona ids", () => {
const storage = {
getItem: () => JSON.stringify(["custom:analyst", 42, "custom:writer"]),
};
assert.deepEqual(readSharedCatalogPersonaIds(storage), [
"custom:analyst",
"custom:writer",
]);
});
test("catalog visibility tolerates unavailable and invalid storage", () => {
assert.deepEqual(readSharedCatalogPersonaIds(null), []);
assert.deepEqual(
readSharedCatalogPersonaIds({ getItem: () => "not-json" }),
[],
);
assert.deepEqual(readSharedCatalogPersonaIds({ getItem: () => "{}" }), []);
assert.deepEqual(
readSharedCatalogPersonaIds({
getItem: () => {
throw new Error("unavailable");
},
}),
[],
);
});
test("catalog visibility persists persona ids without blocking on storage errors", () => {
let storedKey = "";
let storedValue = "";
writeSharedCatalogPersonaIds(["custom:analyst"], {
setItem: (key, value) => {
storedKey = key;
storedValue = value;
},
});
assert.equal(storedKey, "buzz-persona-catalog-visibility-v1");
assert.equal(storedValue, '["custom:analyst"]');
assert.doesNotThrow(() =>
writeSharedCatalogPersonaIds(["custom:analyst"], {
setItem: () => {
throw new Error("unavailable");
},
}),
);
});
test("catalog memory levels read only supported snapshot levels", () => {
const storage = {
getItem: () =>
JSON.stringify({
"custom:analyst": "none",
"custom:writer": "core",
"custom:reviewer": "everything",
"custom:invalid": "secret",
}),
};
assert.deepEqual(readCatalogPersonaMemoryLevels(storage), {
"custom:analyst": "none",
"custom:writer": "core",
"custom:reviewer": "everything",
});
assert.deepEqual(readCatalogPersonaMemoryLevels(null), {});
assert.deepEqual(readCatalogPersonaMemoryLevels({ getItem: () => "[]" }), {});
});
test("catalog memory levels persist without blocking on storage errors", () => {
let storedKey = "";
let storedValue = "";
writeCatalogPersonaMemoryLevels(
{ "custom:analyst": "core" },
{
setItem: (key, value) => {
storedKey = key;
storedValue = value;
},
},
);
assert.equal(storedKey, "buzz-persona-catalog-memory-levels-v1");
assert.equal(storedValue, '{"custom:analyst":"core"}');
assert.doesNotThrow(() =>
writeCatalogPersonaMemoryLevels(
{ "custom:analyst": "core" },
{
setItem: () => {
throw new Error("unavailable");
},
},
),
);
});
test("catalog publication versions read only string revisions", () => {
const storage = {
getItem: () =>
JSON.stringify({
"custom:analyst": "2026-07-22T00:00:00.000Z",
"custom:invalid": 42,
}),
};
assert.deepEqual(readPublishedCatalogPersonaVersions(storage), {
"custom:analyst": "2026-07-22T00:00:00.000Z",
});
assert.deepEqual(readPublishedCatalogPersonaVersions(null), {});
assert.deepEqual(
readPublishedCatalogPersonaVersions({ getItem: () => "[]" }),
{},
);
});
test("catalog publication versions persist without blocking on storage errors", () => {
let storedKey = "";
let storedValue = "";
writePublishedCatalogPersonaVersions(
{ "custom:analyst": "2026-07-22T00:00:00.000Z" },
{
setItem: (key, value) => {
storedKey = key;
storedValue = value;
},
},
);
assert.equal(storedKey, "buzz-persona-catalog-published-versions-v1");
assert.equal(storedValue, '{"custom:analyst":"2026-07-22T00:00:00.000Z"}');
assert.doesNotThrow(() =>
writePublishedCatalogPersonaVersions(
{ "custom:analyst": "2026-07-22T00:00:00.000Z" },
{
setItem: () => {
throw new Error("unavailable");
},
},
),
);
});
@@ -1,157 +0,0 @@
import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas";
const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY =
"buzz-persona-catalog-visibility-v1";
const PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY =
"buzz-persona-catalog-published-versions-v1";
const PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY =
"buzz-persona-catalog-memory-levels-v1";
export type PublishedCatalogPersonaVersions = Record<string, string>;
export type CatalogPersonaMemoryLevels = Record<string, SnapshotMemoryLevel>;
export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel;
const SNAPSHOT_MEMORY_LEVELS = new Set<SnapshotMemoryLevel>([
"none",
"core",
"everything",
]);
function resolveStorage<T extends "getItem" | "setItem">(
storage: Pick<Storage, T> | null | undefined,
): Pick<Storage, T> | null {
if (storage !== undefined) return storage;
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
export function readSharedCatalogPersonaIds(
storage?: Pick<Storage, "getItem"> | null,
): string[] {
const targetStorage = resolveStorage(storage);
if (!targetStorage) return [];
try {
const raw = targetStorage.getItem(PERSONA_CATALOG_VISIBILITY_STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === "string");
} catch {
return [];
}
}
export function writeSharedCatalogPersonaIds(
ids: readonly string[],
storage?: Pick<Storage, "setItem"> | null,
): void {
const targetStorage = resolveStorage(storage);
if (!targetStorage) return;
try {
targetStorage.setItem(
PERSONA_CATALOG_VISIBILITY_STORAGE_KEY,
JSON.stringify(ids),
);
} catch {
// Catalog visibility is a convenience setting and should not block sharing.
}
}
export function readCatalogPersonaMemoryLevels(
storage?: Pick<Storage, "getItem"> | null,
): CatalogPersonaMemoryLevels {
const targetStorage = resolveStorage(storage);
if (!targetStorage) return {};
try {
const raw = targetStorage.getItem(
PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY,
);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, SnapshotMemoryLevel] =>
typeof entry[1] === "string" &&
SNAPSHOT_MEMORY_LEVELS.has(entry[1] as SnapshotMemoryLevel),
),
);
} catch {
return {};
}
}
export function writeCatalogPersonaMemoryLevels(
levels: Readonly<CatalogPersonaMemoryLevels>,
storage?: Pick<Storage, "setItem"> | null,
): void {
const targetStorage = resolveStorage(storage);
if (!targetStorage) return;
try {
targetStorage.setItem(
PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY,
JSON.stringify(levels),
);
} catch {
// Catalog publication state should not block sharing.
}
}
export function readPublishedCatalogPersonaVersions(
storage?: Pick<Storage, "getItem"> | null,
): PublishedCatalogPersonaVersions {
const targetStorage = resolveStorage(storage);
if (!targetStorage) return {};
try {
const raw = targetStorage.getItem(
PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY,
);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
} catch {
return {};
}
}
export function writePublishedCatalogPersonaVersions(
versions: Readonly<PublishedCatalogPersonaVersions>,
storage?: Pick<Storage, "setItem"> | null,
): void {
const targetStorage = resolveStorage(storage);
if (!targetStorage) return;
try {
targetStorage.setItem(
PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY,
JSON.stringify(versions),
);
} catch {
// Catalog publication state should not block sharing.
}
}
@@ -0,0 +1,107 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchPersonaCatalogPublications,
publishPersonaToCatalog,
unpublishPersonaFromCatalog,
type PersonaCatalogPublication,
} from "@/features/agents/lib/personaCatalogRelay";
import { relayClient } from "@/shared/api/relayClient";
import { KIND_PERSONA_CATALOG } from "@/shared/constants/kinds";
export function personaCatalogQueryKey(communityId: string | null) {
return ["persona-catalog", communityId] as const;
}
export function usePersonaCatalogQuery(communityId: string | null) {
return useQuery<PersonaCatalogPublication[]>({
enabled: communityId !== null,
queryKey: personaCatalogQueryKey(communityId),
queryFn: fetchPersonaCatalogPublications,
staleTime: 30_000,
refetchInterval: 120_000,
});
}
export function usePersonaCatalogLiveUpdates(communityId: string | null): void {
const queryClient = useQueryClient();
React.useEffect(() => {
if (!communityId) return;
let disposed = false;
let dispose: (() => Promise<void>) | null = null;
void relayClient
.subscribeLive({ kinds: [KIND_PERSONA_CATALOG], limit: 0 }, () => {
void queryClient.invalidateQueries({
queryKey: personaCatalogQueryKey(communityId),
});
})
.then((unsubscribe) => {
if (disposed) {
void unsubscribe();
} else {
dispose = unsubscribe;
}
})
.catch((error) => {
console.error(
"Failed to subscribe to the community agent catalog",
error,
);
});
const unsubscribeReconnect = relayClient.subscribeToReconnects(() => {
void queryClient.invalidateQueries({
queryKey: personaCatalogQueryKey(communityId),
});
});
return () => {
disposed = true;
unsubscribeReconnect();
if (dispose) void dispose();
};
}, [communityId, queryClient]);
}
export function usePublishPersonaCatalogMutation(communityId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: publishPersonaToCatalog,
onSuccess: (publication) => {
queryClient.setQueryData<PersonaCatalogPublication[]>(
personaCatalogQueryKey(communityId),
(current) => [
publication,
...(current ?? []).filter(
(candidate) =>
candidate.ownerPubkey !== publication.ownerPubkey ||
candidate.sourcePersonaId !== publication.sourcePersonaId,
),
],
);
},
});
}
export function useUnpublishPersonaCatalogMutation(communityId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: unpublishPersonaFromCatalog,
onSuccess: (publication) => {
queryClient.setQueryData<PersonaCatalogPublication[]>(
personaCatalogQueryKey(communityId),
(current) => [
publication,
...(current ?? []).filter(
(candidate) =>
candidate.ownerPubkey !== publication.ownerPubkey ||
candidate.sourcePersonaId !== publication.sourcePersonaId,
),
],
);
},
});
}
+10 -6
View File
@@ -374,9 +374,10 @@ export function AgentsView() {
onCatalogShareLevelChange={(shareLevel) => {
const shareTarget = personas.personaToShare;
if (!shareTarget) return;
personas.setPersonaCatalogShareLevel(
void personas.setPersonaCatalogShareLevel(
shareTarget.persona,
shareLevel,
shareTarget.linkedAgentPubkey,
);
}}
onExport={() => {
@@ -393,7 +394,10 @@ export function AgentsView() {
onPublishCatalogUpdates={() => {
const shareTarget = personas.personaToShare;
if (!shareTarget) return;
personas.publishPersonaCatalogUpdates(shareTarget.persona);
void personas.publishPersonaCatalogUpdates(
shareTarget.persona,
shareTarget.linkedAgentPubkey,
);
}}
open={personas.personaToShare !== null}
persona={personas.personaToShare.persona}
@@ -442,8 +446,8 @@ export function AgentsView() {
{personas.isCatalogDialogOpen ? (
<PersonaCatalogDialog
error={
personas.personasQuery.error instanceof Error
? personas.personasQuery.error
personas.catalogQuery.error instanceof Error
? personas.catalogQuery.error
: null
}
feedbackErrorMessage={
@@ -456,8 +460,8 @@ export function AgentsView() {
? personas.personaNoticeMessage
: null
}
isLoading={personas.personasQuery.isLoading}
isPending={personas.setPersonaActiveMutation.isPending}
isLoading={personas.catalogQuery.isLoading}
isPending={personas.isPending}
onClearFeedback={() => {
personas.clearFeedback("catalog");
}}
@@ -2,13 +2,17 @@ import { cn } from "@/shared/lib/cn";
type PersonaAddedByProps = {
className?: string;
label?: string;
};
export function PersonaAddedBy({ className }: PersonaAddedByProps) {
export function PersonaAddedBy({
className,
label = "You",
}: PersonaAddedByProps) {
return (
<p className={cn("truncate text-xs leading-tight", className)}>
<span className="text-muted-foreground/55">Added by</span>{" "}
<span className="text-muted-foreground">You</span>
<span className="text-muted-foreground">{label}</span>
</p>
);
}
@@ -1,6 +1,7 @@
import * as React from "react";
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
@@ -288,7 +289,16 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
<h3 className="truncate text-xl font-semibold leading-snug">
{persona.displayName}
</h3>
{persona.isBuiltIn ? null : <PersonaAddedBy className="mt-0.5" />}
{persona.isBuiltIn ? null : (
<PersonaAddedBy
className="mt-0.5"
label={
isCatalogPersona(persona) && !persona.catalogSource.isOwn
? "Community member"
: "You"
}
/>
)}
</div>
</div>
@@ -12,7 +12,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks";
import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogVisibility";
import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogRelay";
import {
useOpenDmMutation,
useUpsertCachedChannel,
@@ -740,6 +740,8 @@ export function PersonaShareDialog({
persona,
}: PersonaShareDialogProps) {
const encodeSnapshotMutation = useEncodeAgentSnapshotForSendMutation();
const [pendingCatalogMemoryLevel, setPendingCatalogMemoryLevel] =
React.useState<Exclude<SnapshotMemoryLevel, "none"> | null>(null);
const catalogShareLevels = React.useMemo(
() => [
{ value: "not-shared", label: "Not shared" },
@@ -767,60 +769,107 @@ export function PersonaShareDialog({
);
return (
<SnapshotShareDialog
afterLink={
persona.isBuiltIn ? null : (
<section
className="flex min-h-16 w-full items-center gap-3"
data-testid="persona-share-catalog"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<BookUser className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium">Share to catalog</h3>
<p className="text-xs text-secondary-foreground/75">
Let anyone in this community find and use a copy of this agent.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{catalogShareLevel !== "not-shared" && hasCatalogUpdates ? (
<Button
data-testid="persona-share-publish-catalog-updates"
<>
<SnapshotShareDialog
afterLink={
persona.isBuiltIn ? null : (
<section
className="flex min-h-16 w-full items-center gap-3"
data-testid="persona-share-catalog"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<BookUser className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium">Share to catalog</h3>
<p className="text-xs text-secondary-foreground/75">
Anyone in this community can find and use a copy. Catalog data
is plaintext; secrets and response allowlists are never
included.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{catalogShareLevel !== "not-shared" && hasCatalogUpdates ? (
<Button
data-testid="persona-share-publish-catalog-updates"
disabled={isPending}
onClick={onPublishCatalogUpdates}
size="sm"
variant="outline"
>
Publish updates
</Button>
) : null}
<SnapshotOptionMenu
ariaLabel="What to share in the catalog"
disabled={isPending}
onClick={onPublishCatalogUpdates}
size="sm"
variant="outline"
>
Publish updates
</Button>
) : null}
<SnapshotOptionMenu
ariaLabel="What to share in the catalog"
disabled={isPending}
onValueChange={(nextValue) =>
onCatalogShareLevelChange(
nextValue as CatalogPersonaShareLevel,
)
}
options={catalogShareLevels}
testId="persona-share-catalog-access"
value={catalogShareLevel}
/>
</div>
</section>
)
}
displayName={persona.displayName}
encodeSnapshot={encodeSnapshot}
hasMemoryOptions={linkedAgentPubkey !== null}
isPending={isPending}
onExport={onExport}
onOpenChange={onOpenChange}
onReset={encodeSnapshotMutation.reset}
open={open}
snapshotKind="agent"
testIdPrefix="persona-share"
/>
onValueChange={(nextValue) => {
const shareLevel = nextValue as CatalogPersonaShareLevel;
if (shareLevel === "core" || shareLevel === "everything") {
setPendingCatalogMemoryLevel(shareLevel);
} else {
onCatalogShareLevelChange(shareLevel);
}
}}
options={catalogShareLevels}
testId="persona-share-catalog-access"
value={catalogShareLevel}
/>
</div>
</section>
)
}
displayName={persona.displayName}
encodeSnapshot={encodeSnapshot}
hasMemoryOptions={linkedAgentPubkey !== null}
isPending={isPending}
onExport={onExport}
onOpenChange={onOpenChange}
onReset={encodeSnapshotMutation.reset}
open={open}
snapshotKind="agent"
testIdPrefix="persona-share"
/>
<AlertDialog
onOpenChange={(nextOpen) => {
if (!nextOpen) setPendingCatalogMemoryLevel(null);
}}
open={pendingCatalogMemoryLevel !== null}
>
<AlertDialogContent data-testid="persona-catalog-memory-confirmation">
<AlertDialogHeader>
<AlertDialogTitle>
Publish memories to the catalog?
</AlertDialogTitle>
<AlertDialogDescription>
The selected memory will be stored as plaintext community data.
Anyone in this community can read it and keep a copy, even if you
stop sharing the agent later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid="persona-catalog-memory-confirm"
onClick={() => {
if (pendingCatalogMemoryLevel) {
onCatalogShareLevelChange(pendingCatalogMemoryLevel);
}
setPendingCatalogMemoryLevel(null);
}}
type="button"
>
Publish
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -17,17 +17,27 @@ import {
type AgentSnapshotImportPreview,
type AgentSnapshotImportResult,
} from "@/features/agents/hooks";
import { getPersonaLibraryState } from "@/features/agents/lib/catalog";
import {
getLibraryPersonas,
getPersonaLabelsById,
} from "@/features/agents/lib/catalog";
import {
type CatalogPersonaShareLevel,
readCatalogPersonaMemoryLevels,
readPublishedCatalogPersonaVersions,
readSharedCatalogPersonaIds,
writeCatalogPersonaMemoryLevels,
writePublishedCatalogPersonaVersions,
writeSharedCatalogPersonaIds,
} from "@/features/agents/lib/personaCatalogVisibility";
catalogPersonasFromPublications,
isCatalogPersona,
linkedAgentPubkeyForPersona,
ownCatalogPublication,
} from "@/features/agents/lib/personaCatalogRelay";
import {
usePersonaCatalogLiveUpdates,
usePersonaCatalogQuery,
usePublishPersonaCatalogMutation,
useUnpublishPersonaCatalogMutation,
} from "@/features/agents/lib/usePersonaCatalogRelay";
import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
import { fetchSnapshotBytes } from "@/shared/api/tauriMedia";
import type {
SnapshotFormat,
SnapshotMemoryLevel,
@@ -59,7 +69,15 @@ type PersonaFeedbackSurface = "catalog" | "library";
export function usePersonaActions() {
const queryClient = useQueryClient();
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const communityId = activeCommunity?.id ?? null;
const personasQuery = usePersonasQuery();
const catalogQuery = usePersonaCatalogQuery(communityId);
usePersonaCatalogLiveUpdates(communityId);
const publishCatalogMutation = usePublishPersonaCatalogMutation(communityId);
const unpublishCatalogMutation =
useUnpublishPersonaCatalogMutation(communityId);
const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] =
React.useState(false);
const acpRuntimesQuery = useAcpRuntimesQuery({
@@ -96,13 +114,6 @@ export function usePersonaActions() {
const [snapshotImportConfirmError, setSnapshotImportConfirmError] =
React.useState<string | null>(null);
const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false);
const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState<
string[]
>(readSharedCatalogPersonaIds);
const [catalogPersonaMemoryLevels, setCatalogPersonaMemoryLevels] =
React.useState(readCatalogPersonaMemoryLevels);
const [publishedCatalogPersonaVersions, setPublishedCatalogPersonaVersions] =
React.useState<Record<string, string>>(readPublishedCatalogPersonaVersions);
const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState<
string | null
>(null);
@@ -116,15 +127,19 @@ export function usePersonaActions() {
React.useState(false);
const personas = personasQuery.data ?? [];
const sharedCatalogPersonaIdSet = React.useMemo(
() =>
new Set(
sharedCatalogPersonaIds.filter(
(personaId) => catalogPersonaMemoryLevels[personaId] !== undefined,
),
),
[catalogPersonaMemoryLevels, sharedCatalogPersonaIds],
);
const publications = catalogQuery.data ?? [];
const sharedCatalogPersonaIdSet = React.useMemo(() => {
const ownerPubkey = identityQuery.data?.pubkey?.toLowerCase();
return new Set(
publications
.filter(
(publication) =>
publication.ownerPubkey === ownerPubkey &&
publication.status === "published",
)
.map((publication) => publication.sourcePersonaId),
);
}, [identityQuery.data?.pubkey, publications]);
const availableRuntimes = React.useMemo(
() =>
(acpRuntimesQuery.data ?? []).filter(
@@ -133,9 +148,22 @@ export function usePersonaActions() {
),
[acpRuntimesQuery.data],
);
const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo(
() => getPersonaLibraryState(personas, sharedCatalogPersonaIdSet),
[personas, sharedCatalogPersonaIdSet],
const catalogPersonas = React.useMemo(
() =>
catalogPersonasFromPublications(
publications,
personas,
identityQuery.data?.pubkey,
),
[identityQuery.data?.pubkey, personas, publications],
);
const libraryPersonas = React.useMemo(
() => getLibraryPersonas(personas),
[personas],
);
const personaLabelsById = React.useMemo(
() => getPersonaLabelsById(personas),
[personas],
);
function clearFeedback(
@@ -163,7 +191,7 @@ export function usePersonaActions() {
if ("id" in input) {
const updatedPersona = await updatePersonaMutation.mutateAsync(input);
if (options?.publishCatalogUpdates) {
publishPersonaCatalogUpdates(updatedPersona);
await publishPersonaCatalogUpdates(updatedPersona);
}
setPersonaNoticeMessage(`Updated ${input.displayName}.`);
} else {
@@ -248,6 +276,17 @@ export function usePersonaActions() {
async function handleDelete(persona: AgentPersona) {
clearFeedback("library");
try {
const publication = ownCatalogPublication(
publications,
identityQuery.data?.pubkey,
persona.id,
);
if (publication?.status === "published") {
await unpublishCatalogMutation.mutateAsync({
persona,
previousCreatedAt: publication.createdAt,
});
}
await deletePersonaMutation.mutateAsync(persona.id);
setPersonaNoticeMessage(`Deleted ${persona.displayName}.`);
setPersonaToDelete(null);
@@ -265,7 +304,47 @@ export function usePersonaActions() {
) {
clearFeedback(surface);
try {
await setPersonaActiveMutation.mutateAsync({ id: persona.id, active });
if (active && isCatalogPersona(persona)) {
const isOwnPublication =
persona.catalogSource.ownerPubkey ===
identityQuery.data?.pubkey?.toLowerCase();
const ownLocalPersona = isOwnPublication
? personas.find(
(candidate) =>
candidate.id === persona.catalogSource.sourcePersonaId,
)
: undefined;
if (ownLocalPersona) {
await setPersonaActiveMutation.mutateAsync({
id: ownLocalPersona.id,
active: true,
});
} else {
const snapshot = persona.catalogSource.snapshot;
const fileBytes = await fetchSnapshotBytes({
url: snapshot.url,
filename: snapshot.fileName,
expectedSha256: snapshot.sha256,
expectedSize: snapshot.size,
});
const result = await confirmSnapshotImportMutation.mutateAsync({
fileBytes,
// Catalog publication strips the source response allowlist, but
// fail closed at import too if a malicious entry bypassed it.
keepAllowlist: false,
});
void queryClient.invalidateQueries({ queryKey: personasQueryKey });
void queryClient.invalidateQueries({
queryKey: managedAgentsQueryKey,
});
void queryClient.invalidateQueries({
queryKey: ["user-profile", result.newPubkey.toLowerCase()],
});
}
} else {
await setPersonaActiveMutation.mutateAsync({ id: persona.id, active });
}
setPersonaNoticeMessage(
active
? `Selected ${persona.displayName} for My Agents.`
@@ -372,16 +451,6 @@ export function usePersonaActions() {
linkedAgent: ManagedAgent | undefined,
) {
clearFeedback("library");
if (
sharedCatalogPersonaIdSet.has(persona.id) &&
publishedCatalogPersonaVersions[persona.id] === undefined
) {
setPublishedCatalogPersonaVersions((current) => {
const next = { ...current, [persona.id]: persona.updatedAt };
writePublishedCatalogPersonaVersions(next);
return next;
});
}
setPersonaToShare({
persona,
linkedAgentPubkey: linkedAgent?.pubkey ?? null,
@@ -424,69 +493,113 @@ export function usePersonaActions() {
function getPersonaCatalogShareLevel(
persona: AgentPersona,
): CatalogPersonaShareLevel {
if (!sharedCatalogPersonaIdSet.has(persona.id)) return "not-shared";
return catalogPersonaMemoryLevels[persona.id] ?? "none";
const publication = ownCatalogPublication(
publications,
identityQuery.data?.pubkey,
persona.id,
);
if (
publication?.status !== "published" ||
publication.memoryLevel === null
) {
return "not-shared";
}
return publication.memoryLevel;
}
function setPersonaCatalogShareLevel(
async function setPersonaCatalogShareLevel(
persona: AgentPersona,
shareLevel: CatalogPersonaShareLevel,
) {
linkedAgentPubkey: string | null,
): Promise<void> {
if (persona.isBuiltIn) return;
const visible = shareLevel !== "not-shared";
clearFeedback("library");
setSharedCatalogPersonaIds((current) => {
const next = new Set(current);
if (visible) {
next.add(persona.id);
const publication = ownCatalogPublication(
publications,
identityQuery.data?.pubkey,
persona.id,
);
try {
if (shareLevel === "not-shared") {
await unpublishCatalogMutation.mutateAsync({
persona,
previousCreatedAt: publication?.createdAt,
});
setPersonaNoticeMessage(
`${persona.displayName} is no longer discoverable in the community catalog.`,
);
} else {
next.delete(persona.id);
await publishCatalogMutation.mutateAsync({
persona,
memoryLevel: shareLevel,
linkedAgentPubkey,
previousCreatedAt: publication?.createdAt,
});
setPersonaNoticeMessage(
`Published ${persona.displayName} to the community catalog.`,
);
}
const ids = Array.from(next);
writeSharedCatalogPersonaIds(ids);
return ids;
});
setCatalogPersonaMemoryLevels((current) => {
const next = { ...current };
if (shareLevel !== "not-shared") {
next[persona.id] = shareLevel;
} else {
delete next[persona.id];
}
writeCatalogPersonaMemoryLevels(next);
return next;
});
setPublishedCatalogPersonaVersions((current) => {
const next = { ...current };
if (visible) {
next[persona.id] = persona.updatedAt;
} else {
delete next[persona.id];
}
writePublishedCatalogPersonaVersions(next);
return next;
});
} catch (error) {
setPersonaErrorMessage(
error instanceof Error
? error.message
: "Failed to update catalog sharing.",
);
}
}
function hasPersonaCatalogUpdates(persona: AgentPersona) {
const publishedVersion = publishedCatalogPersonaVersions[persona.id];
const publication = ownCatalogPublication(
publications,
identityQuery.data?.pubkey,
persona.id,
);
return (
sharedCatalogPersonaIdSet.has(persona.id) &&
publishedVersion !== undefined &&
publishedVersion !== persona.updatedAt
publication?.status === "published" &&
publication.sourceUpdatedAt !== persona.updatedAt
);
}
function publishPersonaCatalogUpdates(persona: AgentPersona) {
if (persona.isBuiltIn || !sharedCatalogPersonaIdSet.has(persona.id)) return;
setPublishedCatalogPersonaVersions((current) => {
const next = { ...current, [persona.id]: persona.updatedAt };
writePublishedCatalogPersonaVersions(next);
return next;
});
async function publishPersonaCatalogUpdates(
persona: AgentPersona,
linkedAgentPubkey?: string | null,
): Promise<boolean> {
if (persona.isBuiltIn) return false;
const publication = ownCatalogPublication(
publications,
identityQuery.data?.pubkey,
persona.id,
);
if (
publication?.status !== "published" ||
publication.memoryLevel === null
) {
return false;
}
const managedAgents =
queryClient.getQueryData<ManagedAgent[]>(managedAgentsQueryKey) ?? [];
try {
await publishCatalogMutation.mutateAsync({
persona,
memoryLevel: publication.memoryLevel,
linkedAgentPubkey:
linkedAgentPubkey ??
linkedAgentPubkeyForPersona(persona.id, managedAgents),
previousCreatedAt: publication.createdAt,
});
setPersonaNoticeMessage(
`Published updates to ${persona.displayName} in the community catalog.`,
);
return true;
} catch (error) {
setPersonaErrorMessage(
error instanceof Error
? `${persona.displayName} was saved, but its catalog update failed: ${error.message}`
: `${persona.displayName} was saved, but its catalog update failed.`,
);
return false;
}
}
const isPending =
@@ -498,10 +611,13 @@ export function usePersonaActions() {
setPersonaActiveMutation.isPending ||
exportAgentSnapshotMutation.isPending ||
previewSnapshotImportMutation.isPending ||
confirmSnapshotImportMutation.isPending;
confirmSnapshotImportMutation.isPending ||
publishCatalogMutation.isPending ||
unpublishCatalogMutation.isPending;
return {
personasQuery,
catalogQuery,
acpRuntimesQuery,
createPersonaMutation,
updatePersonaMutation,
+3
View File
@@ -52,6 +52,9 @@ export const KIND_CHANNEL_SORT = 30078;
export const KIND_PERSONA = 30175;
export const KIND_TEAM = 30176;
export const KIND_MANAGED_AGENT = 30177;
// Community-visible catalog publications. Unlike KIND_PERSONA, clients query
// this kind across every author in the active community.
export const KIND_PERSONA_CATALOG = 30178;
export const KIND_USER_STATUS = 30315;
export const KIND_AGENT_OBSERVER_FRAME = 24200;
export const KIND_AGENT_TURN_METRIC = 44200;
+132 -4
View File
@@ -35,6 +35,7 @@ import {
KIND_HUDDLE_STARTED,
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_PERSONA_CATALOG,
KIND_REPO_ANNOUNCEMENT,
KIND_REPO_STATE,
KIND_STREAM_MESSAGE_EDIT,
@@ -104,6 +105,7 @@ type MockPersonaSeed = {
displayName: string;
avatarUrl?: string | null;
systemPrompt: string;
updatedAt?: string;
isActive?: boolean;
sourceTeam?: string | null;
envVars?: Record<string, string>;
@@ -193,6 +195,8 @@ type E2eConfig = {
* (`list/start/stop/restart_managed_agent_runtime`). */
managedAgentRuntimes?: MockManagedAgentRuntimeSeed[];
personas?: MockPersonaSeed[];
/** Community catalog replaceable-event heads returned by relay queries. */
personaCatalogEvents?: RelayEvent[];
teams?: MockTeamSeed[];
relayAgents?: MockRelayAgentSeed[];
agentListDelayMs?: number;
@@ -2121,7 +2125,7 @@ function resetMockPersonas(config?: E2eConfig) {
source_team: persona.sourceTeam ?? null,
env_vars: { ...(persona.envVars ?? {}) },
created_at: now,
updated_at: now,
updated_at: persona.updatedAt ?? now,
});
}
}
@@ -2716,6 +2720,7 @@ const mockChannels: MockChannel[] = [
const mockMessages = new Map<string, RelayEvent[]>();
const mockUserStatuses: RelayEvent[] = [];
const mockReminderEvents: RelayEvent[] = [];
const mockPersonaCatalogEvents: RelayEvent[] = [];
let mockRelayMembers: RawRelayMember[] = [];
const mockSockets = new Map<number, MockSocket>();
let mockWebsocketSendMutexWedged = false;
@@ -2746,6 +2751,16 @@ function resetMockSaveSubscriptions(config: E2eConfig | undefined) {
}));
}
function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) {
mockPersonaCatalogEvents.length = 0;
for (const event of config?.mock?.personaCatalogEvents ?? []) {
mockPersonaCatalogEvents.push({
...event,
tags: event.tags.map((tag) => [...tag]),
});
}
}
// Mesh-compute mock state — TEST-ONLY.
//
// This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__`
@@ -7235,6 +7250,9 @@ async function handleUpdatePersona(args: {
applyMockPersonaBehavior(persona, args.input.behavior);
persona.updated_at = new Date().toISOString();
for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) {
callback();
}
return { ...persona };
}
@@ -7992,6 +8010,35 @@ async function resolveMockUploadDescriptors(
];
}
async function resolveMockUploadDescriptorForBytes(
args: { data: number[]; filename?: string | null },
config: E2eConfig | undefined,
): Promise<RawBlobDescriptor> {
const configured = config?.mock?.uploadDescriptors;
if (configured !== undefined) {
const descriptors = await resolveMockUploadDescriptors(config);
const descriptor = descriptors[0];
if (!descriptor) throw new Error("mock upload returned no descriptor");
return descriptor;
}
const bytes = Uint8Array.from(args.data);
const digest = await crypto.subtle.digest("SHA-256", bytes);
const sha256 = Array.from(new Uint8Array(digest), (value) =>
value.toString(16).padStart(2, "0"),
).join("");
const filename = args.filename ?? "upload.bin";
const isAgentJson = filename.toLowerCase().endsWith(".agent.json");
return {
url: `https://mock.relay/media/${sha256}${isAgentJson ? ".json" : ".bin"}`,
sha256,
size: bytes.length,
type: isAgentJson ? "application/json" : "application/octet-stream",
uploaded: Math.floor(Date.now() / 1000),
filename,
};
}
async function handleSendChannelMessage(
args: {
channelId: string;
@@ -8679,6 +8726,19 @@ function sendToMockSocket(args: {
return;
}
if (filter.kinds?.includes(KIND_PERSONA_CATALOG)) {
const authors = filter.authors?.map((author) => author.toLowerCase());
const sourceIds = filter["#d"];
for (const event of mockPersonaCatalogEvents) {
if (authors && !authors.includes(event.pubkey.toLowerCase())) continue;
const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1];
if (sourceIds && (!sourceId || !sourceIds.includes(sourceId))) continue;
sendWsText(socket.handler, ["EVENT", subId, event]);
}
sendWsText(socket.handler, ["EOSE", subId]);
return;
}
// Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a`
// tag (PR/issue discussions, approvals, review requests).
if (
@@ -8775,6 +8835,31 @@ function sendToMockSocket(args: {
return;
}
if (event.kind === KIND_PERSONA_CATALOG) {
const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1];
if (!sourceId) {
sendWsText(socket.handler, [
"OK",
event.id,
false,
"invalid: persona catalog event missing d tag.",
]);
return;
}
const existingIndex = mockPersonaCatalogEvents.findIndex(
(candidate) =>
candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
candidate.tags.some((tag) => tag[0] === "d" && tag[1] === sourceId),
);
if (existingIndex >= 0) {
mockPersonaCatalogEvents.splice(existingIndex, 1);
}
mockPersonaCatalogEvents.push(event);
emitMockGlobalEvent(event);
sendWsText(socket.handler, ["OK", event.id, true, ""]);
return;
}
if (event.kind === 20001) {
const status = event.content;
if (status === "online" || status === "away" || status === "offline") {
@@ -8893,6 +8978,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockWorkflows();
resetMockMesh();
resetMockUserStatuses();
resetMockPersonaCatalogEvents(config);
resetMockSaveSubscriptions(config);
resetMockPendingCommunityDeepLinks(config);
mockWebsocketSendMutexWedged = false;
@@ -9969,8 +10055,8 @@ export function maybeInstallE2eTauriMocks() {
// Specs assert invocation via __BUZZ_E2E_COMMANDS__.
return true;
case "encode_agent_snapshot_for_send": {
// Return a minimal PNG-shaped payload so the send flow can proceed
// through upload_media_bytes without a real Rust encode step.
// Return the requested wire format so both message sharing (PNG) and
// community catalog publication (JSON) exercise their real branches.
// Optional encodeDelayMs lets specs observe the "preparing" phase before
// the upload begins.
const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0;
@@ -9979,6 +10065,45 @@ export function maybeInstallE2eTauriMocks() {
window.setTimeout(resolve, encodeDelayMs),
);
}
const input = payload as {
id: string;
memoryLevel: "none" | "core" | "everything";
format: "json" | "png";
};
if (input.format === "json") {
const persona = mockPersonas.find(
(candidate) => candidate.id === input.id,
);
const snapshot = {
format: "buzz-agent-snapshot",
version: 1,
definition: {
name: persona?.display_name ?? "E2E Agent",
sourceIsBuiltIn: persona?.is_builtin ?? false,
systemPrompt: persona?.system_prompt ?? "",
runtime: persona?.runtime ?? null,
model: persona?.model ?? null,
provider: persona?.provider ?? null,
respondToAllowlist: persona?.respond_to_allowlist ?? [],
namePool: persona?.name_pool ?? [],
},
profile: {
displayName: persona?.display_name ?? "E2E Agent",
avatarUrl: persona?.avatar_url ?? null,
},
memory: {
level: input.memoryLevel,
entries: [],
},
};
const fileBytes = Array.from(
new TextEncoder().encode(JSON.stringify(snapshot)),
);
return {
fileBytes,
fileName: "e2e-agent.agent.json",
};
}
return {
fileBytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
fileName: "e2e-agent.agent.png",
@@ -10501,7 +10626,10 @@ export function maybeInstallE2eTauriMocks() {
case "pick_and_upload_image":
return (await resolveMockUploadDescriptors(activeConfig))[0] ?? null;
case "upload_media_bytes":
return (await resolveMockUploadDescriptors(activeConfig))[0];
return resolveMockUploadDescriptorForBytes(
payload as { data: number[]; filename?: string | null },
activeConfig,
);
case "fetch_media_bytes": {
// The real command fetches relay media through Rust reqwest and
// replies with raw bytes (`tauri::ipc::Response` → ArrayBuffer). In
+152 -38
View File
@@ -1,8 +1,59 @@
import { expect, test } from "@playwright/test";
import type { RelayEvent } from "@/shared/api/types";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const DEFAULT_MOCK_OWNER_PUBKEY = "deadbeef".repeat(8);
function createCatalogEvent(input: {
ownerPubkey: string;
sourcePersonaId: string;
sourceUpdatedAt: string;
displayName: string;
systemPrompt: string;
createdAt?: number;
}): RelayEvent {
const snapshot = {
url: `https://relay.example/media/${"c".repeat(64)}`,
sha256: "c".repeat(64),
size: 512,
type: "application/json",
fileName: `${input.sourcePersonaId}.agent.json`,
} as const;
return {
id: "1".repeat(64),
pubkey: input.ownerPubkey,
created_at: input.createdAt ?? 1_721_750_400,
kind: 30178,
tags: [
["d", input.sourcePersonaId],
["status", "published"],
["source_updated_at", input.sourceUpdatedAt],
["memory", "none"],
],
content: JSON.stringify({
format: "buzz-persona-catalog",
version: 1,
status: "published",
sourcePersonaId: input.sourcePersonaId,
sourceUpdatedAt: input.sourceUpdatedAt,
memoryLevel: "none",
agent: {
displayName: input.displayName,
avatarUrl: null,
systemPrompt: input.systemPrompt,
runtime: null,
model: null,
provider: null,
},
snapshot,
}),
sig: "2".repeat(128),
};
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -1272,12 +1323,6 @@ test("custom personas share with people and keep export separate", async ({
test("custom personas can be shared to the relay catalog", async ({ page }) => {
const personaId = "custom:catalog-analyst";
await page.addInitScript((legacyPersonaId) => {
localStorage.setItem(
"buzz-persona-catalog-visibility-v1",
JSON.stringify([legacyPersonaId]),
);
}, personaId);
await installMockBridge(page, {
globalAgentConfig: {
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
@@ -1332,7 +1377,10 @@ This deliberately long fenced-code example must not establish the minimum width
).toBeVisible();
await expect(catalogSection).toContainText("Share to catalog");
await expect(catalogSection).toContainText(
"Let anyone in this community find and use a copy of this agent.",
"Anyone in this community can find and use a copy.",
);
await expect(catalogSection).toContainText(
"Catalog data is plaintext; secrets and response allowlists are never included.",
);
const [copyLinkButtonBox, catalogSectionBox, shareMainCardBox] =
await Promise.all([
@@ -1430,37 +1478,6 @@ This deliberately long fenced-code example must not establish the minimum width
.getByRole("button", { name: "Close" })
.click();
await page.evaluate((id) => {
const storageKey = "buzz-persona-catalog-published-versions-v1";
const publishedVersions = JSON.parse(
localStorage.getItem(storageKey) ?? "{}",
) as Record<string, string>;
publishedVersions[id] = "stale";
localStorage.setItem(storageKey, JSON.stringify(publishedVersions));
}, personaId);
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByLabel("Open actions for Catalog Analyst").click();
await page.getByRole("menuitem", { name: "Share" }).click();
await expect(catalogAccess).toHaveText("Agent only");
await expect(publishCatalogUpdatesButton).toBeVisible();
const [catalogAccessBox, publishCatalogUpdatesButtonBox] = await Promise.all([
catalogAccess.boundingBox(),
publishCatalogUpdatesButton.boundingBox(),
]);
expect(
(publishCatalogUpdatesButtonBox?.x ?? 0) +
(publishCatalogUpdatesButtonBox?.width ?? 0),
).toBeLessThan(catalogAccessBox?.x ?? 0);
await publishCatalogUpdatesButton.click();
await expect(publishCatalogUpdatesButton).toHaveCount(0);
await expect(catalogAccess).toHaveText("Agent only");
await page
.getByTestId("persona-share-dialog")
.getByRole("button", { name: "Close" })
.click();
await page.getByLabel("Open actions for Catalog Analyst").click();
await page.getByRole("menuitem", { name: "Share" }).click();
await expect(catalogAccess).toHaveText("Agent only");
@@ -1480,6 +1497,103 @@ This deliberately long fenced-code example must not establish the minimum width
).toHaveCount(0);
});
test("catalog owners can publish local updates to an existing relay entry", async ({
page,
}) => {
const personaId = "custom:catalog-updates";
await installMockBridge(page, {
personas: [
{
id: personaId,
displayName: "Catalog Updates",
systemPrompt: "The locally updated instructions.",
updatedAt: "2026-07-23T17:00:00.000Z",
},
],
personaCatalogEvents: [
createCatalogEvent({
ownerPubkey: DEFAULT_MOCK_OWNER_PUBKEY,
sourcePersonaId: personaId,
sourceUpdatedAt: "2026-07-23T16:00:00.000Z",
displayName: "Catalog Updates",
systemPrompt: "The previously published instructions.",
}),
],
});
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByLabel("Open actions for Catalog Updates").click();
await page.getByRole("menuitem", { name: "Share" }).click();
const catalogAccess = page.getByTestId("persona-share-catalog-access");
const publishButton = page.getByTestId(
"persona-share-publish-catalog-updates",
);
await expect(catalogAccess).toHaveText("Agent only");
await expect(publishButton).toBeVisible();
const [catalogAccessBox, publishButtonBox] = await Promise.all([
catalogAccess.boundingBox(),
publishButton.boundingBox(),
]);
expect(
(publishButtonBox?.x ?? 0) + (publishButtonBox?.width ?? 0),
).toBeLessThan(catalogAccessBox?.x ?? 0);
await publishButton.click();
await expect(publishButton).toHaveCount(0);
await expect(catalogAccess).toHaveText("Agent only");
});
test("a community member can discover and add another member's catalog agent", async ({
page,
}) => {
const personaId = "shared-reviewer";
const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`;
await installMockBridge(page, {
personaCatalogEvents: [
createCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
sourcePersonaId: personaId,
sourceUpdatedAt: "2026-07-23T16:00:00.000Z",
displayName: "Alice’s Reviewer",
systemPrompt: "Review changes for the whole community.",
}),
],
});
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await openPersonaCatalog(page);
const remoteEntry = page.getByTestId(
`persona-catalog-list-item-${remoteCatalogId}`,
);
await expect(remoteEntry).toContainText("Alice’s Reviewer");
await remoteEntry.click();
await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
"Added by Community member",
);
await page
.getByRole("button", {
name: "Add Alice’s Reviewer from Agent Catalog",
})
.click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMANDS__?: string[];
}
).__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "confirm_agent_snapshot_import",
).length ?? 0,
),
)
.toBe(1);
});
test("share access controls include the selected memories", async ({
page,
}) => {
+4
View File
@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import type { RelayEvent } from "@/shared/api/types";
import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features";
export const TEST_IDENTITIES = {
@@ -87,6 +88,7 @@ type MockPersonaSeed = {
displayName: string;
avatarUrl?: string | null;
systemPrompt: string;
updatedAt?: string;
isActive?: boolean;
sourceTeam?: string | null;
envVars?: Record<string, string>;
@@ -210,6 +212,8 @@ type MockBridgeOptions = {
| "stopped";
}>;
personas?: MockPersonaSeed[];
/** Community catalog replaceable-event heads returned by relay queries. */
personaCatalogEvents?: RelayEvent[];
teams?: MockTeamSeed[];
relayAgents?: MockRelayAgentSeed[];
agentListDelayMs?: number;