mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Publish agent catalog entries to relay
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user