diff --git a/desktop/src/features/agents/lib/personaAdoption.test.mjs b/desktop/src/features/agents/lib/personaAdoption.test.mjs new file mode 100644 index 0000000000..de0ad076f8 --- /dev/null +++ b/desktop/src/features/agents/lib/personaAdoption.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countCatalogRepublishers, + personaDefinitionFingerprint, +} from "./personaAdoption.ts"; + +const WES = "a".repeat(64); +const ALICE = "b".repeat(64); +const BOB = "c".repeat(64); + +function publication(ownerPubkey, agent = {}) { + return { + eventId: "e".repeat(64), + ownerPubkey, + sourcePersonaId: "persona-1", + createdAt: 1_700_000_000, + agent: { + displayName: "Brain", + avatarUrl: null, + systemPrompt: "You review pull requests carefully.", + runtime: null, + model: null, + provider: null, + namePool: [], + respondTo: null, + parallelism: null, + ...agent, + }, + }; +} + +test("fingerprint keys on the prompt, ignoring whitespace and case", () => { + const canonical = personaDefinitionFingerprint({ + displayName: "Brain", + systemPrompt: "You review pull requests carefully.", + }); + assert.equal( + personaDefinitionFingerprint({ + displayName: "Renamed Brain", + systemPrompt: " You review pull\nrequests CAREFULLY. ", + }), + canonical, + ); + // Promptless personas fall back to the display name. + assert.equal( + personaDefinitionFingerprint({ displayName: " Brain ", systemPrompt: "" }), + personaDefinitionFingerprint({ displayName: "brain", systemPrompt: " " }), + ); +}); + +test("counts distinct other members with a matching publication", () => { + const count = countCatalogRepublishers( + [ + publication(WES), // the original — never counts + publication(ALICE), + publication(ALICE, { displayName: "Brain (alice)" }), // same member twice + publication(BOB, { displayName: "Big Brain" }), // renamed copy, same prompt + publication(BOB.replace(/c/gu, "d"), { + systemPrompt: "A different persona entirely.", + }), + ], + { + displayName: "Brain", + systemPrompt: "You review pull requests carefully.", + publisherPubkey: WES, + }, + ); + assert.equal(count, 2); +}); + +test("no publisher and no matches yield zero", () => { + assert.equal( + countCatalogRepublishers([], { + displayName: "Brain", + systemPrompt: "prompt", + publisherPubkey: null, + }), + 0, + ); +}); diff --git a/desktop/src/features/agents/lib/personaAdoption.ts b/desktop/src/features/agents/lib/personaAdoption.ts new file mode 100644 index 0000000000..78e9543b8b --- /dev/null +++ b/desktop/src/features/agents/lib/personaAdoption.ts @@ -0,0 +1,61 @@ +/** + * Adoption signals for a catalog persona, derived from what the relay can + * actually show. Adding an entry to your library is a local-only write, so + * true duplication counts don't exist on the relay — but a member who adds a + * persona and then re-shares their copy publishes a second catalog head with + * the same definition. Counting those re-publications gives an honest lower + * bound on how many times the persona has been duplicated. + */ + +import type { PersonaCatalogPublication } from "@/features/agents/lib/personaCatalogRelay"; + +/** + * The identity of a persona definition for adoption matching: its operating + * prompt when it has one (whitespace-insensitive), otherwise its display name. + * Copies keep the source prompt verbatim, so this is the strongest signal a + * publication descends from the same definition. + */ +export function personaDefinitionFingerprint(definition: { + displayName: string; + systemPrompt: string; +}): string { + const prompt = definition.systemPrompt.replace(/\s+/gu, " ").trim(); + if (prompt.length > 0) { + return `prompt:${prompt.toLowerCase()}`; + } + return `name:${definition.displayName.trim().toLowerCase()}`; +} + +/** + * Distinct members other than the publisher who have re-published this + * persona's definition to the community catalog. A lower bound on adoption: + * members who added the persona but never re-shared their copy stay + * invisible. + */ +export function countCatalogRepublishers( + publications: readonly PersonaCatalogPublication[], + entry: { + displayName: string; + systemPrompt: string; + publisherPubkey: string | null; + }, +): number { + const fingerprint = personaDefinitionFingerprint(entry); + const publisher = entry.publisherPubkey?.toLowerCase() ?? null; + const republishers = new Set(); + for (const publication of publications) { + const owner = publication.ownerPubkey.toLowerCase(); + if (owner === publisher) { + continue; + } + if ( + personaDefinitionFingerprint({ + displayName: publication.agent.displayName, + systemPrompt: publication.agent.systemPrompt, + }) === fingerprint + ) { + republishers.add(owner); + } + } + return republishers.size; +} diff --git a/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx b/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx index beeaf59741..afd3ee515f 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx @@ -1,11 +1,14 @@ import * as React from "react"; import { useNavigate } from "@tanstack/react-router"; -import { IdCard } from "lucide-react"; +import { CopyPlus, IdCard } from "lucide-react"; import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { countCatalogRepublishers } from "@/features/agents/lib/personaAdoption"; +import { usePersonaCatalogQuery } from "@/features/agents/lib/usePersonaCatalogRelay"; +import { useCommunities } from "@/features/communities/useCommunities"; import { useAgentPassportBadges } from "@/features/passport/hooks/useAgentPassportBadges"; import { PassportBadgeList } from "@/features/passport/ui/PassportBadges"; import { @@ -123,6 +126,27 @@ export function PersonaCatalogPassport({ const firstAgent = deployedAgents[0] ?? null; const { badges } = useAgentPassportBadges(firstAgent?.pubkey ?? null); + // Adoption: members who added this persona and re-shared their copy publish + // a second catalog head with the same definition. Adds that were never + // re-shared are local-only, so this is an honest lower bound — and + // cross-server runs are invisible from a single relay entirely. + const { activeCommunity } = useCommunities(); + const catalogQuery = usePersonaCatalogQuery(activeCommunity?.id ?? null); + const republisherCount = React.useMemo( + () => + countCatalogRepublishers(catalogQuery.data ?? [], { + displayName: persona.displayName, + publisherPubkey, + systemPrompt: persona.systemPrompt, + }), + [ + catalogQuery.data, + persona.displayName, + persona.systemPrompt, + publisherPubkey, + ], + ); + const navigate = useNavigate(); const openPassport = React.useCallback( (pubkey: string) => { @@ -195,6 +219,17 @@ export function PersonaCatalogPassport({ appears here once one is.

)} + {republisherCount > 0 ? ( +

+ + Duplicated by {republisherCount} other{" "} + {republisherCount === 1 ? "member" : "members"} — re-shared to + this community’s catalog. +

+ ) : null} {publisherPubkey ? (