mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
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:
106
desktop/src/features/passport/lib/badges.test.mjs
Normal file
106
desktop/src/features/passport/lib/badges.test.mjs
Normal 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/);
|
||||
});
|
||||
204
desktop/src/features/passport/lib/badges.ts
Normal file
204
desktop/src/features/passport/lib/badges.ts
Normal 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;
|
||||
}
|
||||
101
desktop/src/features/passport/ui/PassportBadges.tsx
Normal file
101
desktop/src/features/passport/ui/PassportBadges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user