feat: Bot Mode avatars default to deterministic blob faces drawn from the agent's name

New agents now get a blobatar — a deterministic soft-body face generated
from the bot's name (same name, same face, forever) — as the default
shapes mode, with full manual control:

- Face follows the name live while typing in New Agent
- Randomize re-rolls the seed; Lock face pins the current one so a later
  rename can't change it (Unlock returns to name-following)
- Any of the six silhouettes (round/organic/boxy/nub/cloud/sun) can be
  pinned via frozen-per-major trait positions while the rest stays
  name-derived
- Classic geometric shapes remain one click away, and existing bots keep
  their stored looks untouched

Wiring: blobatar@0.2.0 (zero deps, ~3.7KB) exported through the plugin
SDK (blobatarSvg / Blobatar), feature-detected in plugin.js with a
legacy-shape fallback for older desktops. Blob shape strings are
'blobatar[:seed[:kind]]' inside the existing meta.shape field, so
persistence, cross-machine ui_meta sync, and the roster's PNG backfill
(data-bot-face tag preserved) all work unchanged.
This commit is contained in:
Teknium
2026-08-18 11:39:55 -07:00
parent f8767d1e71
commit a77ee88ce2
6 changed files with 322 additions and 5 deletions
+1
View File
@@ -113,6 +113,7 @@
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"blobatar": "0.2.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
+167 -4
View File
@@ -75,6 +75,9 @@ const SkillsView = typeof sdk === 'undefined' ? undefined : sdk.SkillsView
// so those builds keep the staged checklists for remote targets.
const skillsViewRoutesConnections = Boolean(SkillsView && SkillsView.supportsFixedConnection)
const Streamdown = typeof sdk === 'undefined' ? undefined : sdk.Streamdown
// Deterministic blob avatars (name → face). Feature-detected: older SDKs
// without the export fall back to the legacy math-face shapes below.
const blobatarSvg = typeof sdk === 'undefined' ? undefined : sdk.blobatarSvg
// Budgeted render loop (fps cap + observability pause + dormancy + teardown).
// Feature-detected: older desktops fall back to the hand-rolled clock below.
const createBudgetedLoop = typeof sdk === 'undefined' ? undefined : sdk.createBudgetedLoop
@@ -834,6 +837,62 @@ function defaultShapeFor(name) {
return AVATAR_SHAPES[hash % AVATAR_SHAPES.length]
}
// ── blobatar shapes mode (default for new agents) ───────────────────────────
// Deterministic soft-body faces drawn from a string. Shape strings:
// 'blobatar' — the face follows the bot's NAME (renaming the
// bot re-rolls the face, live in the dialog)
// 'blobatar:<seed>' — seed locked (the 🔒 lock / 🎲 randomize picks)
// 'blobatar:<seed>:<kind>' — plus one of the six silhouettes pinned
// 'blobatar::<kind>' — silhouette pinned, seed still follows the name
// Bot names are slugs (NAME_RE) and generated seeds are base36, so ':' never
// appears inside a segment. Colors come from the library's own name-derived
// palette (contrast-guaranteed) — the classic color swatches don't apply.
const BLOB_KINDS = ['round', 'organic', 'boxy', 'nub', 'cloud', 'sun']
// Trait positions at the center of each silhouette band. Band thresholds are
// frozen per blobatar major (0.28 / 0.58 / 0.72 / 0.84 / 0.93).
const BLOB_KIND_TRAIT = { round: 0.14, organic: 0.43, boxy: 0.65, nub: 0.78, cloud: 0.885, sun: 0.965 }
function isBlobShape(shape) {
return shape === 'blobatar' || (typeof shape === 'string' && shape.startsWith('blobatar:'))
}
function parseBlobShape(shape, name) {
const parts = typeof shape === 'string' ? shape.split(':') : []
const seedPart = parts[1] || ''
const kind = BLOB_KINDS.includes(parts[2]) ? parts[2] : ''
return { seed: seedPart || name || 'agent', seedPart, kind }
}
function blobShapeString(seedPart, kind) {
if (kind) {
return `blobatar:${seedPart}:${kind}`
}
return seedPart ? `blobatar:${seedPart}` : 'blobatar'
}
/** Static SVG markup for a blob face, tagged data-bot-face so the roster's
* PNG backfill (pushLocalAvatars → rasterizeSvgToPng) still finds it. */
function blobMarkup(shape, name, size) {
if (!blobatarSvg) {
return null
}
const { seed, kind } = parseBlobShape(shape, name)
const opts = { size }
if (kind) {
opts.traits = { shape: BLOB_KIND_TRAIT[kind] }
}
try {
return blobatarSvg(seed, opts).replace('<svg ', '<svg data-bot-face=' + JSON.stringify(name) + ' ')
} catch {
return null
}
}
/** The colored body of the avatar (no eyes). Platonic solids are a filled
* silhouette + translucent internal edge lines (the projected wireframe);
* legacy flat shapes keep their old geometry so stored picks still render. */
@@ -1447,6 +1506,26 @@ function BotFace({ shape, color, image, size = 36, name = 'agent', mood = 'idle'
})
}
// Blobatar shapes: the library draws the whole face (body + eyes + its own
// name-derived palette). Inline SVG via innerHTML so the roster PNG
// backfill's `svg[data-bot-face=…]` query still finds it; the math clock
// ignores it (no data-hb-math). Falls back to the legacy math face when the
// SDK predates the export.
if (isBlobShape(shape)) {
const markup = blobMarkup(shape, name, size)
if (markup) {
return jsx('span', {
'aria-hidden': true,
style: { width: size, height: size, display: 'block', lineHeight: 0 },
dangerouslySetInnerHTML: { __html: markup }
})
}
// Older SDK without blobatar: legacy deterministic shape from the name.
shape = defaultShapeFor(name)
}
// Sigils are line art (no filled body) — the math clock rebuilds filled
// outlines, which would turn a stored sigil pick into a blank circle.
// Keep the legacy static render for them so old picks still draw.
@@ -2071,7 +2150,88 @@ function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generate
: null,
tab === 'bot'
? jsxs('div', {
? isBlobShape(shape) && blobatarSvg
? (() => {
const { seedPart, kind } = parseBlobShape(shape, pickerName)
const locked = Boolean(seedPart)
return jsxs('div', {
className: 'grid justify-items-center gap-3',
children: [
// Silhouette pins: Auto (name decides) + the six blob kinds.
jsx('div', {
style: {
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: '6px',
justifyItems: 'center'
},
children: ['', ...BLOB_KINDS].map(k =>
jsx(
'button',
{
type: 'button',
title: k || 'Auto — the name decides',
className: cn(
'flex items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover)',
k === kind && !image && 'ring-1 ring-(--ui-accent)'
),
style: { width: 44, height: 44 },
onClick: () => {
onImage(null)
onShape(blobShapeString(seedPart, k))
},
children: k
? jsx(BotFace, { shape: blobShapeString(seedPart, k), color, size: 32, name: pickerName })
: jsx('span', { className: 'text-[0.6rem] text-(--ui-text-tertiary)', children: 'Auto' })
},
k || 'auto'
)
)
}),
jsxs('div', {
className: 'flex items-center gap-1',
children: [
jsxs(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
onClick: () => {
onImage(null)
onShape(blobShapeString(Math.random().toString(36).slice(2, 10), kind))
},
children: [jsx(Codicon, { name: 'refresh', className: 'mr-1 text-[0.8rem]' }), 'Randomize']
}),
jsxs(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
title: locked
? 'Unlock — the face follows the agent\u2019s name again'
: 'Keep this exact face even if the name changes',
onClick: () => onShape(blobShapeString(locked ? '' : pickerName, kind)),
children: [
jsx(Codicon, { name: locked ? 'unlock' : 'lock', className: 'mr-1 text-[0.8rem]' }),
locked ? 'Unlock' : 'Lock face'
]
})
]
}),
jsx('div', {
className: 'text-center text-[0.65rem] text-(--ui-text-quaternary)',
children: locked ? 'Face locked — renaming won\u2019t change it.' : 'Face follows the name.'
}),
jsx(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
className: 'text-(--ui-text-tertiary)',
onClick: () => onShape(defaultShapeFor(pickerName)),
children: 'Classic shapes'
})
]
})
})()
: jsxs('div', {
className: 'grid justify-items-center gap-3',
children: [
jsx('div', {
@@ -2081,11 +2241,12 @@ function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generate
gap: '6px',
justifyItems: 'center'
},
children: AVATAR_PICKER_SHAPES.map(s =>
children: (blobatarSvg ? ['blobatar', ...AVATAR_PICKER_SHAPES] : AVATAR_PICKER_SHAPES).map(s =>
jsx(
'button',
{
type: 'button',
title: s === 'blobatar' ? 'Blob face — drawn from the agent\u2019s name' : undefined,
className: cn(
'flex items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover)',
s === shape && !image && 'ring-1 ring-(--ui-accent)'
@@ -5764,7 +5925,9 @@ function CreateAgentDialog({ open, onClose, roster }) {
const flightRef = useRef(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [shape, setShape] = useState('circle')
// Default shapes mode: deterministic blob face drawn from the agent's name
// (falls back to the legacy shape vocabulary on older SDKs).
const [shape, setShape] = useState(blobatarSvg ? 'blobatar' : 'circle')
const [color, setColor] = useState(AVATAR_COLORS[3])
const [image, setImage] = useState(null)
const [advanced, setAdvanced] = useState(false)
@@ -5861,7 +6024,7 @@ function CreateAgentDialog({ open, onClose, roster }) {
setName('')
setTitle('')
setDescription('')
setShape('circle')
setShape(blobatarSvg ? 'blobatar' : 'circle')
setColor(AVATAR_COLORS[3])
setImage(null)
setAdvanced(false)
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
function load({ blobatarSvg } = {}) {
const values = new Map()
const atom = initial => {
const slot = { get: () => values.get(slot), set: value => values.set(slot, value) }
values.set(slot, initial)
return slot
}
const sdkStub = new Proxy(
{ blobatarSvg },
{ get: (target, key) => (key in target ? target[key] : undefined) }
)
const context = {
atom,
PALETTE_AREA: 'palette',
COMPOSER_AREAS: { middleware: 'middleware' },
document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } },
host: { state: { profile: { listen: () => undefined }, gateway: { listen: () => undefined } } },
sdk: sdkStub
}
const source = pluginSource
.replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
.replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
.replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '')
.replace(/^import .* from 'react'\r?\n/m, '')
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
.replace('export default {', 'globalThis.plugin = {')
.concat(`
globalThis.__blob = { isBlobShape, parseBlobShape, blobShapeString, blobMarkup, BLOB_KINDS, BLOB_KIND_TRAIT };
`)
vm.runInNewContext(source, context, { filename: 'plugin.js' })
return context.__blob
}
test('blob shape strings round-trip through parse/build', () => {
const b = load()
assert.equal(b.isBlobShape('blobatar'), true)
assert.equal(b.isBlobShape('blobatar:seed123'), true)
assert.equal(b.isBlobShape('blobatar::sun'), true)
assert.equal(b.isBlobShape('circle'), false)
assert.equal(b.isBlobShape(undefined), false)
// Unlocked: seed follows the name.
assert.equal(JSON.stringify(b.parseBlobShape('blobatar', 'inbox-triage')), JSON.stringify({ seed: 'inbox-triage', seedPart: '', kind: '' }))
// Locked seed.
assert.equal(JSON.stringify(b.parseBlobShape('blobatar:abc123', 'inbox-triage')), JSON.stringify({ seed: 'abc123', seedPart: 'abc123', kind: '' }))
// Pinned silhouette, unlocked seed.
assert.equal(JSON.stringify(b.parseBlobShape('blobatar::cloud', 'inbox-triage')), JSON.stringify({ seed: 'inbox-triage', seedPart: '', kind: 'cloud' }))
// Unknown silhouette is ignored, never trusted.
assert.equal(b.parseBlobShape('blobatar:abc:mystery', 'x').kind, '')
assert.equal(b.blobShapeString('', ''), 'blobatar')
assert.equal(b.blobShapeString('abc', ''), 'blobatar:abc')
assert.equal(b.blobShapeString('abc', 'sun'), 'blobatar:abc:sun')
assert.equal(b.blobShapeString('', 'sun'), 'blobatar::sun')
})
test('every silhouette has a trait position inside its frozen band', () => {
const b = load()
const bands = { round: [0, 0.28], organic: [0.28, 0.58], boxy: [0.58, 0.72], nub: [0.72, 0.84], cloud: [0.84, 0.93], sun: [0.93, 1] }
for (const kind of b.BLOB_KINDS) {
const v = b.BLOB_KIND_TRAIT[kind]
assert.equal(typeof v, 'number', kind)
assert.ok(v >= bands[kind][0] && v < bands[kind][1], `${kind} trait ${v} outside band`)
}
})
test('blobMarkup renders via the SDK export, tags data-bot-face, pins traits', () => {
const calls = []
const b = load({
blobatarSvg: (seed, opts) => {
calls.push({ seed, opts })
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"></svg>'
}
})
const markup = b.blobMarkup('blobatar', 'inbox-triage', 56)
assert.ok(markup.startsWith('<svg data-bot-face="inbox-triage" '), 'roster PNG backfill needs the data-bot-face tag')
assert.equal(calls[0].seed, 'inbox-triage')
assert.equal(calls[0].opts.size, 56)
assert.equal('traits' in calls[0].opts, false)
b.blobMarkup('blobatar:abc:sun', 'inbox-triage', 32)
assert.equal(calls[1].seed, 'abc')
assert.equal(calls[1].opts.traits.shape, b.BLOB_KIND_TRAIT.sun)
})
test('blobMarkup degrades to null when the SDK lacks the export or the renderer throws', () => {
const withoutSdk = load()
assert.equal(withoutSdk.blobMarkup('blobatar', 'x', 32), null)
const throwing = load({
blobatarSvg: () => {
throw new Error('boom')
}
})
assert.equal(throwing.blobMarkup('blobatar', 'x', 32), null)
})
+4
View File
@@ -915,6 +915,10 @@ export { useStore as useValue } from '@nanostores/react'
* the app root, so their queries cache, dedupe, poll (`refetchInterval`), and
* invalidate exactly like core screens — no hand-rolled atoms or polls. */
export { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
/** Deterministic soft-body avatars from any string (name → face). String
* renderer for rasterization; React component for live rendering. */
export { blobatar as blobatarSvg } from 'blobatar/blob'
export { Blobatar } from 'blobatar/react'
/** Plugin-local reactive state (share between a trigger and its panel, poll
* loops, cross-component signals) — the same primitive `host.state` uses. */
export { atom, computed } from 'nanostores'
+42
View File
@@ -99,6 +99,7 @@
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"blobatar": "0.2.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -1834,6 +1835,7 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1850,6 +1852,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1866,6 +1869,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1882,6 +1886,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1898,6 +1903,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1914,6 +1920,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1930,6 +1937,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1946,6 +1954,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1962,6 +1971,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1978,6 +1988,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1994,6 +2005,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2010,6 +2022,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2026,6 +2039,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2042,6 +2056,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2058,6 +2073,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2074,6 +2090,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2090,6 +2107,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2106,6 +2124,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2122,6 +2141,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2138,6 +2158,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2154,6 +2175,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2170,6 +2192,7 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2186,6 +2209,7 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2202,6 +2226,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2218,6 +2243,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2234,6 +2260,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -7664,6 +7691,20 @@
"react": ">=17.0.1"
}
},
"node_modules/blobatar": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/blobatar/-/blobatar-0.2.0.tgz",
"integrity": "sha512-TL4blQf9fu+EzsFEKMjh7PVORnf42G9HdpaXzdLR9dTHcMSYJWEj5fPeLhsXTN5YFCNstZaRAQRAWyhW8lRAdw==",
"license": "MIT",
"peerDependencies": {
"react": ">=18"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -17633,6 +17674,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
+2 -1
View File
@@ -60,7 +60,8 @@ Remote-creation notes:
Every Bot gets a face:
- **Geometric faces** — 7 shapes × 10 colors, with blinking eyes that scan while the Bot works.
- **Blob faces** (default) — a deterministic soft-body face drawn from the Bot's name: same name, same face, forever. While you type a name in New Agent the face follows it live; hit **Randomize** to re-roll, **Lock face** to keep the one you like even if the name changes, or pin one of the six silhouettes (round, organic, boxy, nub, cloud, sun) while everything else still comes from the name.
- **Geometric faces** — the classic 7 shapes × 10 colors, with blinking eyes that scan while the Bot works.
- **An uploaded image** — any picture you like.
- **An AI-generated portrait** — when an image backend is configured, generated in place (this rides the standard `image.generate` RPC and works over both local and remote gateways).
- **A pixel pet** — a companion from the [petdex gallery](./features/pets.md) that bounces beside the avatar while the Bot is busy. Run `hermes pets` in a terminal to explore the gallery.