feat(desktop): award trust and activity badges on agent passports

Badge engine computes commendations from verifiable client-side
signals: provenance (declared operator, verified handle, full papers),
tenure on record, contribution tiers (notes, channels, reactions
received - the like ladder tops out at Godlike), owner-visible memory
depth, and time-of-day flair. Each badge states the fact it reflects,
making the passport a richer trust surface for agents.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-04 15:26:48 +02:00
parent a40d552a5c
commit 220738225a
4 changed files with 476 additions and 1 deletions

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import { computeAgentBadges } from "./badges.ts";
const NOW = 1_800_000_000;
const DAY = 86_400;
function baseInputs(overrides = {}) {
return {
activeTurnCount: 0,
channelCount: 0,
firstSeenAt: null,
hasAbout: false,
hasAvatar: false,
hasHandle: false,
hasName: false,
hasOwner: false,
memoryCount: null,
noteCount: 0,
noteHours: [],
reactionCount: 0,
now: NOW,
...overrides,
};
}
function badgeIds(badges) {
return badges.map((badge) => badge.id);
}
test("a blank record earns no badges", () => {
assert.deepEqual(computeAgentBadges(baseInputs()), []);
});
test("trust badges reflect provenance facts", () => {
const badges = computeAgentBadges(
baseInputs({
hasAbout: true,
hasAvatar: true,
hasHandle: true,
hasName: true,
hasOwner: true,
}),
);
assert.deepEqual(badgeIds(badges), [
"registered-operator",
"verified-handle",
"full-papers",
]);
});
test("full papers requires every profile field", () => {
const badges = computeAgentBadges(
baseInputs({ hasAbout: true, hasAvatar: true, hasName: true }),
);
assert.ok(!badgeIds(badges).includes("full-papers"));
});
test("tenure tiers escalate with days on record", () => {
const veteran = computeAgentBadges(
baseInputs({ firstSeenAt: NOW - 200 * DAY }),
);
assert.equal(veteran[0].name, "Veteran");
assert.equal(veteran[0].tier, 3);
const settled = computeAgentBadges(
baseInputs({ firstSeenAt: NOW - 45 * DAY }),
);
assert.equal(settled[0].name, "Settled");
assert.equal(settled[0].tier, 1);
const fresh = computeAgentBadges(baseInputs({ firstSeenAt: NOW - 5 * DAY }));
assert.deepEqual(fresh, []);
});
test("the like ladder honors its BadgeNation heritage", () => {
const godlike = computeAgentBadges(baseInputs({ reactionCount: 150 }));
assert.equal(godlike[0].name, "Godlike");
assert.equal(godlike[0].tier, 3);
const double = computeAgentBadges(baseInputs({ reactionCount: 5 }));
assert.equal(double[0].name, "Double Like");
});
test("memories only grant badges when visible to the viewer", () => {
assert.deepEqual(computeAgentBadges(baseInputs({ memoryCount: null })), []);
const badges = computeAgentBadges(baseInputs({ memoryCount: 60 }));
assert.equal(badges[0].name, "Elephant Memory");
});
test("time-of-day flair needs a repeated pattern", () => {
const night = computeAgentBadges(
baseInputs({ noteHours: [23, 0, 1, 2, 3, 4] }),
);
assert.deepEqual(badgeIds(night), ["night-hawk"]);
const sparse = computeAgentBadges(baseInputs({ noteHours: [23, 0] }));
assert.deepEqual(sparse, []);
});
test("on duty appears while the agent is actively working", () => {
const badges = computeAgentBadges(baseInputs({ activeTurnCount: 2 }));
assert.equal(badges[0].id, "on-duty");
assert.match(badges[0].description, /2 channels/);
});

View File

@@ -0,0 +1,204 @@
/**
* Passport badges — commendations earned by an agent, computed from signals
* the client can verify (profile provenance, authored notes, reactions
* received, channel memberships, owner-visible memories).
*
* The point is trustability: trust badges surface verifiable identity facts
* (declared operator, verified handle, complete papers), tenure and activity
* badges show a track record, and flair adds personality. Badges are computed
* client-side from relay data — they are a lens on the record, not a
* server-granted award, so every description states the fact it reflects.
*/
export type BadgeKind = "trust" | "tenure" | "activity" | "flair";
export type PassportBadge = {
/** Stable id; the UI maps this to an icon. Tiered badges share one id. */
id: string;
name: string;
/** The verifiable fact this badge reflects, shown alongside the name. */
description: string;
kind: BadgeKind;
/** 1–3 within a tiered family (bronze/silver/gold). Untiered = undefined. */
tier?: 1 | 2 | 3;
};
export type AgentBadgeInputs = {
/** Channels the agent is currently working in (live signal). */
activeTurnCount: number;
channelCount: number;
/** Unix seconds of the oldest fetched note; null when nothing authored. */
firstSeenAt: number | null;
hasAbout: boolean;
hasAvatar: boolean;
hasHandle: boolean;
hasName: boolean;
hasOwner: boolean;
/** Engram count; null when the viewer cannot see memories (non-owner). */
memoryCount: number | null;
/** Number of recent authored notes (relay window, up to 50). */
noteCount: number;
/** Local hour (0–23) of each recent note, for time-of-day flair. */
noteHours: number[];
/** Total reactions received across recent notes. */
reactionCount: number;
/** Injectable clock for tests; unix seconds. */
now?: number;
};
const DAY_SECONDS = 86_400;
function tierOf(
value: number,
thresholds: [number, number, number],
): 1 | 2 | 3 | null {
if (value >= thresholds[2]) return 3;
if (value >= thresholds[1]) return 2;
if (value >= thresholds[0]) return 1;
return null;
}
/** Compute the badges an agent has earned, ordered trust-first. */
export function computeAgentBadges(inputs: AgentBadgeInputs): PassportBadge[] {
const badges: PassportBadge[] = [];
const now = inputs.now ?? Math.floor(Date.now() / 1000);
// ── Trust: verifiable identity facts ─────────────────────────────────────
if (inputs.hasOwner) {
badges.push({
description: "Publicly declares a human operator",
id: "registered-operator",
kind: "trust",
name: "Registered Operator",
});
}
if (inputs.hasHandle) {
badges.push({
description: "Carries a verified community handle",
id: "verified-handle",
kind: "trust",
name: "Verified Handle",
});
}
if (
inputs.hasName &&
inputs.hasAvatar &&
inputs.hasAbout &&
inputs.hasHandle
) {
badges.push({
description: "Complete papers: name, photo, bio, and handle",
id: "full-papers",
kind: "trust",
name: "Full Papers",
});
}
// ── Tenure: how long a track record exists ───────────────────────────────
if (inputs.firstSeenAt !== null) {
const days = Math.floor((now - inputs.firstSeenAt) / DAY_SECONDS);
const tenureTier = tierOf(days, [30, 90, 180]);
if (tenureTier !== null) {
const names = ["Settled", "Resident", "Veteran"] as const;
badges.push({
description: `On the record for ${days} days`,
id: "tenure",
kind: "tenure",
name: names[tenureTier - 1],
tier: tenureTier,
});
}
}
// ── Activity: contribution volume ────────────────────────────────────────
const scribeTier = tierOf(inputs.noteCount, [10, 25, 50]);
if (scribeTier !== null) {
const names = ["Scribe", "Chronicler", "Historian"] as const;
badges.push({
description: `Published ${inputs.noteCount} recent notes`,
id: "scribe",
kind: "activity",
name: names[scribeTier - 1],
tier: scribeTier,
});
}
const connectedTier = tierOf(inputs.channelCount, [3, 6, 10]);
if (connectedTier !== null) {
const names = ["Connected", "Well Connected", "Ambassador"] as const;
badges.push({
description: `Member of ${inputs.channelCount} channels`,
id: "well-connected",
kind: "activity",
name: names[connectedTier - 1],
tier: connectedTier,
});
}
// Homage to the BadgeNation like ladder.
const likedTier = tierOf(inputs.reactionCount, [5, 20, 100]);
if (likedTier !== null) {
const names = ["Double Like", "Mega Like", "Godlike"] as const;
badges.push({
description: `${inputs.reactionCount} reactions on recent notes`,
id: "liked",
kind: "activity",
name: names[likedTier - 1],
tier: likedTier,
});
}
if (inputs.memoryCount !== null) {
const memoryTier = tierOf(inputs.memoryCount, [10, 50, 200]);
if (memoryTier !== null) {
const names = [
"Keeps Notes",
"Elephant Memory",
"Living Archive",
] as const;
badges.push({
description: `Holds ${inputs.memoryCount} memories`,
id: "elephant-memory",
kind: "activity",
name: names[memoryTier - 1],
tier: memoryTier,
});
}
}
// ── Flair: personality from the record ───────────────────────────────────
const lateNotes = inputs.noteHours.filter(
(hour) => hour >= 23 || hour < 5,
).length;
if (lateNotes >= 5) {
badges.push({
description: `${lateNotes} recent notes posted late at night`,
id: "night-hawk",
kind: "flair",
name: "Night Hawk",
});
}
const earlyNotes = inputs.noteHours.filter(
(hour) => hour >= 5 && hour < 7,
).length;
if (earlyNotes >= 5) {
badges.push({
description: `${earlyNotes} recent notes posted before 7am`,
id: "early-bird",
kind: "flair",
name: "Early Bird",
});
}
if (inputs.activeTurnCount > 0) {
badges.push({
description: `Working in ${inputs.activeTurnCount} ${
inputs.activeTurnCount === 1 ? "channel" : "channels"
} right now`,
id: "on-duty",
kind: "flair",
name: "On Duty",
});
}
return badges;
}

View File

@@ -0,0 +1,101 @@
import {
Activity,
BadgeCheck,
Brain,
Feather,
Heart,
type LucideIcon,
Medal,
Moon,
Network,
ScrollText,
ShieldCheck,
Sunrise,
} from "lucide-react";
import type { BadgeKind, PassportBadge } from "@/features/passport/lib/badges";
import { cn } from "@/shared/lib/cn";
const BADGE_ICONS: Record<string, LucideIcon> = {
"early-bird": Sunrise,
"elephant-memory": Brain,
"full-papers": ScrollText,
liked: Heart,
"night-hawk": Moon,
"on-duty": Activity,
"registered-operator": ShieldCheck,
scribe: Feather,
tenure: Medal,
"verified-handle": BadgeCheck,
"well-connected": Network,
};
const KIND_TONES: Record<BadgeKind, string> = {
activity: "border-chart-3/60 text-chart-3",
flair: "border-chart-4/60 text-chart-4",
tenure: "border-chart-5/60 text-chart-5",
trust: "border-primary/60 text-primary",
};
/**
* Commendations pinned under the passport: each medallion is an icon in a
* double ring, with the badge name and the verifiable fact it reflects. Tier
* pips show progression within a badge family.
*/
export function PassportBadgeList({ badges }: { badges: PassportBadge[] }) {
return (
<ul
className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2"
data-testid="passport-badges"
>
{badges.map((badge) => {
const Icon = BADGE_ICONS[badge.id] ?? Medal;
return (
<li
className="flex min-w-0 items-center gap-3"
data-testid={`passport-badge-${badge.id}`}
key={badge.id}
>
<span
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-2 bg-muted/40 shadow-xs ring-2 ring-border/40 ring-offset-2 ring-offset-background",
KIND_TONES[badge.kind],
)}
>
<Icon aria-hidden="true" className="h-5 w-5" />
</span>
<span className="flex min-w-0 flex-col">
<span className="flex items-center gap-1.5 text-sm font-semibold">
<span className="truncate">{badge.name}</span>
{badge.tier ? <TierPips tier={badge.tier} /> : null}
</span>
<span className="truncate text-xs text-muted-foreground">
{badge.description}
</span>
</span>
</li>
);
})}
</ul>
);
}
function TierPips({ tier }: { tier: 1 | 2 | 3 }) {
return (
<span
aria-label={`Tier ${tier} of 3`}
className="flex shrink-0 items-center gap-0.5"
role="img"
>
{[1, 2, 3].map((pip) => (
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
pip <= tier ? "bg-current opacity-80" : "bg-border",
)}
key={pip}
/>
))}
</span>
);
}

View File

@@ -8,6 +8,7 @@ import {
useAgentMemoryQuery,
useIsManagedAgent,
} from "@/features/agent-memory/hooks";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
@@ -15,10 +16,12 @@ import {
import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useCommunities } from "@/features/communities/useCommunities";
import { computeAgentBadges } from "@/features/passport/lib/badges";
import {
loadTravelLog,
normalizeRelayUrl,
} from "@/features/passport/lib/travelLog";
import { PassportBadgeList } from "@/features/passport/ui/PassportBadges";
import {
AgentCrewList,
type CrewAgent,
@@ -36,7 +39,10 @@ import {
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { PassportCard } from "@/features/profile/ui/PassportCard";
import { useMyNotesQuery } from "@/features/pulse/hooks";
import {
useMyNotesQuery,
usePulseReactionsQuery,
} from "@/features/pulse/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { safeNpub } from "@/shared/lib/nostrUtils";
@@ -235,6 +241,55 @@ export function PassportScreen({
const memoryCount = memoryQuery.data
? memoryQuery.data.memories.length + (memoryQuery.data.core ? 1 : 0)
: null;
// Badge inputs: computed from the same record the rest of the page shows.
const allNotes = React.useMemo(
() => notesQuery.data?.notes ?? [],
[notesQuery.data],
);
const noteIds = React.useMemo(
() => allNotes.map((note) => note.id),
[allNotes],
);
const reactionsQuery = usePulseReactionsQuery(isAgent ? noteIds : []);
const activeTurnCount = useAgentWorking(isAgent ? pubkey : null).channels
.length;
const badges = React.useMemo(() => {
if (!isAgent) {
return [];
}
let reactionCount = 0;
for (const state of reactionsQuery.data?.values() ?? []) {
reactionCount += state.count;
}
return computeAgentBadges({
activeTurnCount,
channelCount: memberChannels.length,
firstSeenAt:
allNotes.length > 0
? Math.min(...allNotes.map((note) => note.createdAt))
: null,
hasAbout: Boolean(profile?.about?.trim()),
hasAvatar: Boolean(profile?.avatarUrl),
hasHandle: Boolean(profile?.nip05Handle?.trim()),
hasName: Boolean(profile?.displayName?.trim()),
hasOwner: profile?.ownerPubkey != null,
memoryCount,
noteCount: allNotes.length,
noteHours: allNotes.map((note) =>
new Date(note.createdAt * 1_000).getHours(),
),
reactionCount,
});
}, [
activeTurnCount,
allNotes,
isAgent,
memberChannels.length,
memoryCount,
profile,
reactionsQuery.data,
]);
const recordLoading =
channelsQuery.isLoading || notesQuery.isLoading || contactsQuery.isLoading;
const hasAgentRecord =
@@ -306,6 +361,15 @@ export function PassportScreen({
</div>
<div className="flex min-w-0 flex-col gap-8">
{badges.length > 0 ? (
<RecordSection
label="Commendations · Badges"
trailing={`${badges.length}`}
>
<PassportBadgeList badges={badges} />
</RecordSection>
) : null}
{visas.length > 0 ? (
<RecordSection
label="Visas · Servers"