feat(desktop): show persona duplication count in the catalog passport

Adding a catalog entry is a local-only write, so true duplication counts
don't exist on the relay — but a member who adds a persona and re-shares
their copy publishes a second catalog head with the same definition.
Count distinct other members with a matching operating prompt and show
it as an honest lower bound ("Duplicated by N other members"). Document
that cross-server runs are invisible from a single relay and what relay
support the full signals would need.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-04 16:46:40 +02:00
parent 594c2fc6d7
commit 5b53d1f01d
5 changed files with 211 additions and 4 deletions

View File

@@ -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,
);
});

View File

@@ -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<string>();
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;
}

View File

@@ -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.
</p>
)}
{republisherCount > 0 ? (
<p
className="flex items-center gap-2 text-sm text-muted-foreground"
data-testid="persona-catalog-adoption"
>
<CopyPlus className="h-3.5 w-3.5 shrink-0" />
Duplicated by {republisherCount} other{" "}
{republisherCount === 1 ? "member" : "members"} — re-shared to
this community’s catalog.
</p>
) : null}
{publisherPubkey ? (
<div>
<Button

View File

@@ -70,13 +70,26 @@ One badge, three tiers, based on days since the agent's oldest note on record.
want portable, attestable badges (e.g. relay- or operator-signed), NIP-58
badge events are the natural upgrade path.
## Adoption (catalog entries)
The agent catalog's passport section shows a duplication count: distinct
members other than the publisher who re-published the same persona definition
(matched by operating prompt) to the community catalog. Adding an entry is a
local-only write, so members who added but never re-shared stay invisible —
the count is an honest lower bound, like everything else here. Engine:
`features/agents/lib/personaAdoption.ts`.
## Wants relay support first
Signals that belong here but aren't attributable to a pubkey on the relay yet:
- **Adopted** — how many other operators duplicated/reused this agent's
persona. Catalog copies are local-only today (`catalogSource` on the
adopter's disk); needs an adoption event kind.
- **Adopted** — the full duplication count (every add, not just re-shares).
Catalog copies are local-only today (`catalogSource` on the adopter's
disk); needs an adoption event kind.
- **Runs on other servers** — a community is one relay, and the client has no
cross-relay registry to ask. Needs either portable operator-signed
attestations (NIP-58 style) that travel with the agent, or a directory
service relays opt into.
- **Workhorse** — completed agent turns/sessions. Turn completion is a live
observer signal, not a persisted count.
- **Automated** — workflow runs executed. `WorkflowRun` has no trigger

View File

@@ -65,6 +65,15 @@ test("catalog entry shows the deployed agent's passport record", async ({
sourcePersonaId: "nadia-persona",
systemPrompt: "Ship relay fixes and review incoming pull requests.",
}),
// Alice re-shared her copy of the persona (same operating prompt) —
// that is the observable trace of a duplication.
createCatalogEvent({
displayName: "nadia (fork)",
id: "6".repeat(64),
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
sourcePersonaId: "nadia-persona-fork",
systemPrompt: "Ship relay fixes and review incoming pull requests.",
}),
],
relayAgents: [
{
@@ -75,6 +84,8 @@ test("catalog entry shows the deployed agent's passport record", async ({
],
});
await openCatalog(page);
// Both entries share the prompt; "nadia" sorts before "nadia (fork)" and is
// auto-selected, so the detail pane shows the original.
const passportSection = page.getByTestId("persona-catalog-passport");
await expect(passportSection).toBeVisible();
@@ -95,6 +106,11 @@ test("catalog entry shows the deployed agent's passport record", async ({
passportSection.getByTestId("persona-catalog-publisher-passport"),
).toHaveText(/View your passport/);
// Alice's re-shared copy of the same prompt counts as a duplication.
await expect(
passportSection.getByTestId("persona-catalog-adoption"),
).toHaveText(/Duplicated by 1 other member/);
await waitForAnimations(page);
await page
.getByTestId("persona-catalog-detail-pane")