Skip to content
171 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.1/**
2 * An agent's face (docs.g1t.sh/guides/agents/, "Its face"): the little bot
3 * every agent wears when it has no uploaded picture. A face is a `look`,
4 * seven choices the agent's owner can make; an agent with no look of its
5 * own wears the one drawn from its `avatar_seed`, so every agent has a
6 * stable face from the moment it is made, and keeps it through a rename.
7 *
8 * The site draws the face (apps/web/app/components/agent-face.tsx) and the
9 * agents service keeps it (the `look` column, JSON). No value imports, so
10 * services test it under Node as it is. Wire shapes are snake_case.
11 */
12
13export const FACE_SHAPES = ["round", "square", "squircle", "blob", "hex"] as const;
14/** Ten named colours, each with a light and a dark rendering that read on both themes. */
15export const FACE_COLORS = ["lavender", "mint", "peach", "sky", "pink", "lemon", "teal", "coral", "moss", "slate"] as const;
16export const FACE_EYES = ["dots", "round", "wide", "happy", "visor", "sleepy"] as const;
17export const FACE_MOUTHS = ["smile", "grin", "flat", "open", "none"] as const;
18export const FACE_ANTENNAS = ["none", "single", "double", "bulb"] as const;
19export const FACE_ACCESSORIES = ["none", "headphones", "bow", "glasses", "hat", "spark"] as const;
20export const FACE_PATTERNS = ["none", "stripe", "dots", "gradient"] as const;
21
22export type FaceShape = (typeof FACE_SHAPES)[number];
23export type FaceColor = (typeof FACE_COLORS)[number];
24export type FaceEyes = (typeof FACE_EYES)[number];
25export type FaceMouth = (typeof FACE_MOUTHS)[number];
26export type FaceAntenna = (typeof FACE_ANTENNAS)[number];
27export type FaceAccessory = (typeof FACE_ACCESSORIES)[number];
28export type FacePattern = (typeof FACE_PATTERNS)[number];
29
30/** What an agent's face is made of. Every field is one of its listed choices. */
31export type AgentLook = {
32 shape: FaceShape;
33 color: FaceColor;
34 eyes: FaceEyes;
35 mouth: FaceMouth;
36 antenna: FaceAntenna;
37 accessory: FaceAccessory;
38 pattern: FacePattern;
39};
40
41/** Each part of a look and its choices, in the order the Face editor shows them. */
42export const LOOK_OPTIONS: { [K in keyof AgentLook]: readonly AgentLook[K][] } = {
43 shape: FACE_SHAPES,
44 color: FACE_COLORS,
45 eyes: FACE_EYES,
46 mouth: FACE_MOUTHS,
47 antenna: FACE_ANTENNAS,
48 accessory: FACE_ACCESSORIES,
49 pattern: FACE_PATTERNS,
50};
51
52export const LOOK_KEYS = Object.keys(LOOK_OPTIONS) as (keyof AgentLook)[];
53
54/** FNV-1a, 32 bits: a seed as a number. */
55function fnv(text: string): number {
56 let h = 0x811c9dc5;
57 for (let i = 0; i < text.length; i++) {
58 h ^= text.charCodeAt(i);
59 h = Math.imul(h, 0x01000193);
60 }
61 return h >>> 0;
62}
63
64/** One choice for a part, from the seed and the part's name: parts vary independently. */
65function pick<T>(seed: string, part: string, options: readonly T[], weights?: readonly number[]): T {
66 const roll = fnv(`${part}:${seed}`) >>> 8;
67 if (!weights) return options[roll % options.length]!;
68 const total = weights.reduce((sum, w) => sum + w, 0);
69 let at = roll % total;
70 for (let i = 0; i < options.length; i++) {
71 at -= weights[i]!;
72 if (at < 0) return options[i]!;
73 }
74 return options[options.length - 1]!;
75}
76
77/**
78 * The face an agent wears when it has chosen none: the same for the same
79 * seed everywhere, and different from its neighbours' as far as seven
80 * parts allow. Plainer choices (no antenna, no accessory, no pattern) are
81 * weighted so faces read as a set rather than a costume party, and `none`
82 * never lands on both the eyes' expression and the mouth.
83 */
84export function lookFromSeed(seed: string): AgentLook {
85 const s = seed || "agent";
86 const look: AgentLook = {
87 shape: pick(s, "shape", FACE_SHAPES, [3, 2, 4, 2, 2]),
88 color: pick(s, "color", FACE_COLORS),
89 eyes: pick(s, "eyes", FACE_EYES, [3, 3, 2, 2, 2, 1]),
90 mouth: pick(s, "mouth", FACE_MOUTHS, [4, 2, 2, 2, 1]),
91 antenna: pick(s, "antenna", FACE_ANTENNAS, [4, 3, 2, 2]),
92 accessory: pick(s, "accessory", FACE_ACCESSORIES, [7, 1, 1, 1, 1, 1]),
93 pattern: pick(s, "pattern", FACE_PATTERNS, [6, 1, 1, 2]),
94 };
95 if (look.mouth === "none" && look.eyes === "visor") look.mouth = "flat";
96 return look;
97}
98
99/**
100 * A look as it was sent or stored, checked: every part present and one of
101 * its choices. Null when it is not one (a service refuses it, a page draws
102 * the seed's face instead).
103 */
104export function readLook(raw: unknown): AgentLook | null {
105 const value = typeof raw === "string" ? parse(raw) : raw;
106 if (!value || typeof value !== "object" || Array.isArray(value)) return null;
107 const given = value as Record<string, unknown>;
108 const look: Partial<AgentLook> = {};
109 for (const key of LOOK_KEYS) {
110 const options = LOOK_OPTIONS[key] as readonly string[];
111 const choice = given[key];
112 if (typeof choice !== "string" || !options.includes(choice)) return null;
113 (look as Record<string, string>)[key] = choice;
114 }
115 return look as AgentLook;
116}
117
118function parse(text: string): unknown {
119 try {
120 return JSON.parse(text);
121 } catch {
122 return null;
123 }
124}
125
126/** Whether two looks are the same face. */
127export function sameLook(a: AgentLook | null | undefined, b: AgentLook | null | undefined): boolean {
128 if (!a || !b) return a == b;
129 return LOOK_KEYS.every((key) => a[key] === b[key]);
130}
131
132/** The face an agent shows: its own look, else the one its seed draws. */
133export function lookOf(agent: { look?: AgentLook | null; avatar_seed?: string | null; id?: string | null; handle?: string | null; name?: string | null }): AgentLook {
134 return agent.look ?? lookFromSeed(agent.avatar_seed || agent.id || agent.handle || agent.name || "agent");
135}
136
137/**
138 * A random look, for the Shuffle button: `random` is `Math.random` unless
139 * a test hands in its own. Every part is drawn evenly, so shuffling shows
140 * the whole range.
141 */
142export function randomLook(random: () => number = Math.random): AgentLook {
143 const draw = <T>(options: readonly T[]): T => options[Math.min(options.length - 1, Math.floor(random() * options.length))]!;
144 return {
145 shape: draw(FACE_SHAPES),
146 color: draw(FACE_COLORS),
147 eyes: draw(FACE_EYES),
148 mouth: draw(FACE_MOUTHS),
149 antenna: draw(FACE_ANTENNAS),
150 accessory: draw(FACE_ACCESSORIES),
151 pattern: draw(FACE_PATTERNS),
152 };
153}
154
155/**
156 * What a personality preset suggests for a face that has not been chosen:
157 * a precise voice looks precise. Only a suggestion, applied by the Face
158 * editor when the person has made no choice; it never changes a look an
159 * agent already has.
160 */
161export const PRESET_EXPRESSIONS: Record<string, Partial<AgentLook>> = {
162 crisp: { eyes: "dots", mouth: "flat" },
163 friendly: { eyes: "happy", mouth: "smile" },
164 socratic: { eyes: "round", mouth: "open" },
165 terse: { eyes: "visor", mouth: "flat" },
166};
167
168/** The seed's face with the preset's expression laid over it, for an agent that has chosen no look. */
169export function suggestedLook(seed: string, preset: string | null | undefined): AgentLook {
170 return { ...lookFromSeed(seed), ...(preset ? (PRESET_EXPRESSIONS[preset] ?? {}) : {}) };
171}