From b2e5b1d42007175d61a23859b8e38f8ec756770c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 27 Aug 2026 11:57:40 -0500 Subject: [PATCH 01/30] refactor(desktop): convert Bot Mode from hand-written jsx() calls to TSX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes-bots/plugin.js was 16,193 lines of hand-written JSX compiler output — it imported { jsx, jsxs } from 'react/jsx-runtime' and called them directly. Because the file was .js, eslint (scoped to plugins/**/*.{ts,tsx}) and tsc (allowJs: false) both skipped it entirely, so none of the design system, import-fence, or type rules that govern the rest of the app ever reached the largest UI surface we ship on by default. Converting it back to JSX is an inverse-compile, not a rewrite, so it is done by script rather than by hand: - scripts/codemod/dejsx.mjs rewrites jsx()/jsxs() calls into JSX elements. 735 conversions, none skipped. Comments between children become {/* … */} containers, since a bare // in children position is text. - scripts/codemod/verify.mjs proves the result. It recompiles the .tsx through esbuild — an implementation independent of the codemod — and compares it to the original after normalizing away esbuild's own rewrites (quote style, void 0, numeric format, string/template folding, export hoisting) and alpha-renaming every binding per scope. Output: IDENTICAL across 333,711 normalized characters. Two rewrite classes are semantics-preserving but not byte-identical, so they are named and counted rather than hidden: 12 spread-children folds (JSX has no spread-children syntax; children={[...xs]} can only be written {xs}, which React flattens identically and which is the idiom the whole ecosystem writes) and 1 redundant key prop (passed both in props and as the third argument; React's jsx runtime never copies key into props). No behavior change. 16,193 lines become 16,050 of real TSX. --- apps/desktop/scripts/codemod/dejsx.mjs | 251 + apps/desktop/scripts/codemod/verify.mjs | 267 + .../hermes-bots/{plugin.js => plugin.tsx} | 11389 ++++++++-------- 3 files changed, 6141 insertions(+), 5766 deletions(-) create mode 100644 apps/desktop/scripts/codemod/dejsx.mjs create mode 100644 apps/desktop/scripts/codemod/verify.mjs rename apps/desktop/src/plugins/hermes-bots/{plugin.js => plugin.tsx} (68%) diff --git a/apps/desktop/scripts/codemod/dejsx.mjs b/apps/desktop/scripts/codemod/dejsx.mjs new file mode 100644 index 000000000000..cb0b75b20836 --- /dev/null +++ b/apps/desktop/scripts/codemod/dejsx.mjs @@ -0,0 +1,251 @@ +/** + * Inverse-compile `jsx()` / `jsxs()` runtime calls back into JSX syntax. + * + * hermes-bots/plugin.js is hand-written JSX compiler output: it imports + * { jsx, jsxs } from 'react/jsx-runtime' and calls them directly. That makes + * the conversion to real JSX a deterministic AST transform rather than a + * rewrite. + * + * Usage: node dejsx.mjs + */ +import { readFileSync, writeFileSync } from 'node:fs' + +import generate from '@babel/generator' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' +import * as t from '@babel/types' + +const gen = generate.default ?? generate +const walk = traverse.default ?? traverse + +const [, , inPath, outPath] = process.argv + +const source = readFileSync(inPath, 'utf8') +const ast = parse(source, { + sourceType: 'module', + plugins: ['jsx'], + attachComment: true +}) + +const stats = { jsx: 0, jsxs: 0, skipped: [] } + +/** `'div'` → intrinsic; `Foo` → component; `Foo.Bar` → member. */ +function toJsxName(node) { + if (t.isStringLiteral(node)) { + // Only bare tag names are expressible as JSX intrinsics. + return /^[a-z][a-z0-9]*$/i.test(node.value) ? t.jsxIdentifier(node.value) : null + } + + if (t.isIdentifier(node)) { + return t.jsxIdentifier(node.name) + } + + if (t.isMemberExpression(node) && !node.computed) { + const object = toJsxName(node.object) + const property = t.isIdentifier(node.property) ? t.jsxIdentifier(node.property.name) : null + + return object && property ? t.jsxMemberExpression(object, property) : null + } + + return null +} + +/** JSX attribute names permit dashes and colons, so most keys pass through. */ +function toAttrName(key, computed) { + if (computed) { + return null + } + + const raw = t.isIdentifier(key) ? key.name : t.isStringLiteral(key) ? key.value : null + + return raw && /^[A-Za-z_$][-:A-Za-z0-9_$]*$/.test(raw) ? t.jsxIdentifier(raw) : null +} + +/** + * A string child is safe as bare JSXText only when it survives a round trip: + * no JSX metacharacters, and no leading/trailing whitespace (which JSX trims). + */ +function textIsSafe(value) { + return value.length > 0 && !/[{}<>]/.test(value) && value === value.trim() && !/\n/.test(value) +} + +function toChild(node) { + if (t.isJSXElement(node) || t.isJSXFragment(node)) { + return node + } + + if (t.isStringLiteral(node) && textIsSafe(node.value)) { + return t.jsxText(node.value) + } + + // `null` / `false` children are pure noise once inlined, but they carry + // meaning inside conditionals, so only drop the standalone literals. + if (t.isNullLiteral(node) || (t.isBooleanLiteral(node) && node.value === false)) { + return null + } + + return t.jsxExpressionContainer(node) +} + +/** + * Comments between children have to become `{/* … *␟/}` — a bare `//` in JSX + * children position is literal text, not a comment. Line comments are + * rewritten as block comments so they survive the move. + */ +function commentChild(comments) { + const empty = t.jsxEmptyExpression() + + empty.innerComments = comments.map(comment => ({ + type: 'CommentBlock', + value: comment.type === 'CommentLine' ? ` ${comment.value.trim()} ` : comment.value + })) + + return t.jsxExpressionContainer(empty) +} + +function childrenFrom(node) { + const items = t.isArrayExpression(node) ? node.elements : [node] + const children = [] + + for (const item of items) { + if (!item) { + continue + } + + if (item.leadingComments?.length) { + children.push(commentChild(item.leadingComments)) + item.leadingComments = null + } + + const child = t.isSpreadElement(item) ? t.jsxExpressionContainer(item.argument) : toChild(item) + + if (child) { + children.push(child) + } + + if (item.trailingComments?.length) { + children.push(commentChild(item.trailingComments)) + item.trailingComments = null + } + } + + return children +} + +function convert(path) { + const { node } = path + const [type, props, key] = node.arguments + + const name = toJsxName(type) + + if (!name) { + stats.skipped.push(`${node.loc?.start.line}: dynamic element type`) + + return + } + + if (!t.isObjectExpression(props)) { + stats.skipped.push(`${node.loc?.start.line}: non-literal props`) + + return + } + + const attributes = [] + let children = [] + + if (key) { + attributes.push(t.jsxAttribute(t.jsxIdentifier('key'), t.jsxExpressionContainer(key))) + } + + for (const prop of props.properties) { + if (t.isSpreadElement(prop)) { + attributes.push(t.jsxSpreadAttribute(prop.argument)) + + continue + } + + if (!t.isObjectProperty(prop)) { + stats.skipped.push(`${node.loc?.start.line}: object method in props`) + + return + } + + const attrName = toAttrName(prop.key, prop.computed) + + if (!attrName) { + stats.skipped.push(`${node.loc?.start.line}: unexpressible prop key`) + + return + } + + if (attrName.name === 'children') { + children = childrenFrom(prop.value) + + continue + } + + // `foo={true}` is idiomatic as a bare `foo`; string values print unquoted. + const value = + t.isStringLiteral(prop.value) && !/[\n"]/.test(prop.value.value) + ? t.stringLiteral(prop.value.value) + : t.isBooleanLiteral(prop.value) && prop.value.value === true + ? null + : t.jsxExpressionContainer(prop.value) + + const attribute = t.jsxAttribute(attrName, value) + + // Carry the explanatory comments that sit above props — this file's + // comments are most of its documentation. + if (prop.leadingComments?.length) { + attribute.leadingComments = prop.leadingComments + } + + attributes.push(attribute) + } + + const selfClosing = children.length === 0 + const element = t.jsxElement( + t.jsxOpeningElement(name, attributes, selfClosing), + selfClosing ? null : t.jsxClosingElement(name), + children, + selfClosing + ) + + t.inherits(element, node) + path.replaceWith(element) + + stats[node.callee.name] += 1 +} + +walk(ast, { + CallExpression: { + // Post-order: inner calls are already JSXElements by the time we rebuild + // the parent, so they slot straight in as children. + exit(path) { + const callee = path.node.callee + + if (t.isIdentifier(callee) && (callee.name === 'jsx' || callee.name === 'jsxs')) { + convert(path) + } + } + } +}) + +// The runtime import is what we just eliminated. +walk(ast, { + ImportDeclaration(path) { + if (path.node.source.value === 'react/jsx-runtime') { + path.remove() + } + } +}) + +const output = gen(ast, { jsescOption: { minimal: true }, retainLines: false, comments: true }, source) + +writeFileSync(outPath, output.code) + +console.log(`jsx: ${stats.jsx} jsxs: ${stats.jsxs} skipped: ${stats.skipped.length}`) + +for (const skip of stats.skipped.slice(0, 40)) { + console.log(` skip ${skip}`) +} diff --git a/apps/desktop/scripts/codemod/verify.mjs b/apps/desktop/scripts/codemod/verify.mjs new file mode 100644 index 000000000000..a1c009777631 --- /dev/null +++ b/apps/desktop/scripts/codemod/verify.mjs @@ -0,0 +1,267 @@ +/** + * Prove the de-JSX codemod was lossless. + * + * Recompiles the generated .tsx back down to jsx-runtime calls with esbuild, + * then compares it against the original hand-written source. Both sides are + * normalized through the same printer so the diff reflects semantics, not + * formatting. + * + * jsx/jsxs selection is deliberately collapsed: the pair differ only in React's + * dev-mode static-children key warning, and esbuild picks between them on its + * own rules rather than the ones the file was hand-written with. + */ +import { readFileSync } from 'node:fs' + +import generate from '@babel/generator' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' +import * as t from '@babel/types' +import { transformSync } from 'esbuild' + +const gen = generate.default ?? generate +const walk = traverse.default ?? traverse + +const [, , originalPath, convertedPath] = process.argv + +const allowed = { spreadChildren: 0, redundantKeyProp: 0 } + +function normalize(code, plugins) { + const ast = parse(code, { sourceType: 'module', plugins }) + + walk(ast, { + /** + * JSX has no spread-children syntax: `children: [...items.map(f), x]` can + * only be written `{items.map(f)}`, which compiles to + * `children: [items.map(f), x]`. React flattens nested array children and + * the mapped elements keep their explicit keys, so the two are equivalent + * — this is the idiom the whole ecosystem writes. Fold the spread away on + * the original side so the comparison doesn't flag it, and count it. + */ + ObjectProperty(path) { + if (!t.isIdentifier(path.node.key, { name: 'children' }) || !t.isArrayExpression(path.node.value)) { + return + } + + path.node.value.elements = path.node.value.elements.map(element => { + if (!t.isSpreadElement(element)) { + return element + } + + allowed.spreadChildren += 1 + + return element.argument + }) + }, + // Drop captured raw text so both sides re-print literals canonically — + // one quote style, and `48000` rather than esbuild's `48e3`. + 'StringLiteral|NumericLiteral'(path) { + delete path.node.extra + }, + // Template chunks keep their raw text, so an emoji written as a surrogate + // pair on one side and a code point on the other reads as a diff. + TemplateElement(path) { + const { cooked } = path.node.value + + if (typeof cooked === 'string') { + path.node.value.raw = cooked.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + } + }, + /** + * Some call sites pass `key` twice — inside props AND as the third + * argument. React's jsx runtime takes the key from the third argument and + * never copies `key` into props, so the props copy is dead either way. + */ + CallExpression(path) { + const callee = path.node.callee + const [, props] = path.node.arguments + + if (!t.isIdentifier(callee) || callee.name !== 'jsx' || !t.isObjectExpression(props)) { + return + } + + props.properties = props.properties.filter(prop => { + if (t.isObjectProperty(prop) && !prop.computed && t.isIdentifier(prop.key, { name: 'key' })) { + allowed.redundantKeyProp += 1 + + return false + } + + return true + }) + }, + // esbuild folds adjacent string/template concatenation into one literal. + BinaryExpression: { + exit(path) { + const { operator, left, right } = path.node + + if (operator !== '+') { + return + } + + if (t.isStringLiteral(left) && t.isStringLiteral(right)) { + path.replaceWith(t.stringLiteral(left.value + right.value)) + + return + } + + // `` `a${x}b` + 'c' `` → `` `a${x}bc` `` + if (t.isTemplateLiteral(left) && t.isStringLiteral(right)) { + const quasis = left.quasis.map(quasi => t.cloneNode(quasi)) + const last = quasis[quasis.length - 1] + + last.value = { raw: last.value.cooked + right.value, cooked: last.value.cooked + right.value } + path.replaceWith(t.templateLiteral(quasis, left.expressions)) + + return + } + + // `'a' + `b${x}` ` → `` `ab${x}` `` + if (t.isStringLiteral(left) && t.isTemplateLiteral(right)) { + const quasis = right.quasis.map(quasi => t.cloneNode(quasi)) + const first = quasis[0] + + first.value = { raw: left.value + first.value.cooked, cooked: left.value + first.value.cooked } + path.replaceWith(t.templateLiteral(quasis, right.expressions)) + + return + } + + // `` `a${x}` + `b${y}` `` → `` `a${x}b${y}` `` — the seam quasis merge. + if (t.isTemplateLiteral(left) && t.isTemplateLiteral(right)) { + const leftQuasis = left.quasis.map(quasi => t.cloneNode(quasi)) + const rightQuasis = right.quasis.map(quasi => t.cloneNode(quasi)) + const seam = leftQuasis.pop() + const head = rightQuasis.shift() + const merged = (seam.value.cooked ?? '') + (head.value.cooked ?? '') + + seam.value = { raw: merged, cooked: merged } + path.replaceWith( + t.templateLiteral([...leftQuasis, seam, ...rightQuasis], [...left.expressions, ...right.expressions]) + ) + } + } + }, + // esbuild prefers a template literal when it saves escaping. + TemplateLiteral: { + exit(path) { + if (path.node.expressions.length === 0) { + path.replaceWith(t.stringLiteral(path.node.quasis[0].value.cooked ?? '')) + } + } + }, + // esbuild rewrites `undefined` to `void 0`. + UnaryExpression(path) { + if (path.node.operator === 'void' && t.isNumericLiteral(path.node.argument, { value: 0 })) { + path.replaceWith(t.identifier('undefined')) + } + }, + // esbuild emits `import { jsx as _jsx }`; the original imports it bare. + Identifier(path) { + if (path.node.name === '_jsx' || path.node.name === '_jsxs') { + path.node.name = 'jsx' + } + + if (path.node.name === 'jsxs') { + path.node.name = 'jsx' + } + }, + ImportDeclaration(path) { + if (path.node.source.value === 'react/jsx-runtime') { + path.remove() + } + }, + // esbuild hoists the spread helper for `{...props}` on intrinsics. + VariableDeclarator(path) { + if (t.isIdentifier(path.node.id) && /^__(spread|assign|objRest)/.test(path.node.id.name)) { + path.remove() + } + } + }) + + // esbuild hoists `export default {…}` into + // `var X = {…}; export { X as default }`. + const body = ast.program.body + const exportIndex = body.findIndex( + node => + (t.isExportDefaultDeclaration(node) && t.isIdentifier(node.declaration)) || + (t.isExportNamedDeclaration(node) && + !node.declaration && + node.specifiers.length === 1 && + t.isExportSpecifier(node.specifiers[0]) && + t.isIdentifier(node.specifiers[0].exported, { name: 'default' })) + ) + + if (exportIndex !== -1) { + const exported = body[exportIndex] + const name = t.isExportDefaultDeclaration(exported) + ? exported.declaration.name + : exported.specifiers[0].local.name + const declIndex = body.findIndex( + node => + t.isVariableDeclaration(node) && + node.declarations.length === 1 && + t.isIdentifier(node.declarations[0].id, { name }) + ) + + if (declIndex !== -1) { + body[exportIndex] = t.exportDefaultDeclaration(body[declIndex].declarations[0].init) + body.splice(declIndex, 1) + } + } + + // esbuild renames locals that shadow an outer binding (`displayName` → + // `displayName2`). Canonically rename every binding in declaration order on + // both sides so the comparison tests alpha-equivalence rather than spelling. + // Numbered per scope, not globally: one extra binding on one side then + // shifts only its own scope instead of desynchronizing the whole file. + let scopeId = 0 + + walk(ast, { + Scopable(path) { + const bindings = Object.values(path.scope.bindings).sort( + (a, b) => (a.identifier.start ?? 0) - (b.identifier.start ?? 0) + ) + const scope = (scopeId += 1) + + bindings.forEach((binding, index) => { + path.scope.rename(binding.identifier.name, `__s${scope}_${index}`) + }) + } + }) + + return gen(ast, { comments: false, compact: true, jsescOption: { minimal: true } }).code +} + +const original = normalize(readFileSync(originalPath, 'utf8'), []) +const recompiled = normalize( + transformSync(readFileSync(convertedPath, 'utf8'), { + loader: 'tsx', + jsx: 'automatic', + format: 'esm', + target: 'esnext' + }).code, + [] +) + +console.log( + `allowed rewrites — spread children folded: ${allowed.spreadChildren}, redundant key props dropped: ${allowed.redundantKeyProp}` +) + +if (original === recompiled) { + console.log(`IDENTICAL — ${original.length} chars of normalized output match`) + process.exit(0) +} + +console.log(`DIFFER — original ${original.length} chars, recompiled ${recompiled.length} chars`) + +// Report the first divergence with surrounding context so it can be chased. +let i = 0 +while (i < original.length && original[i] === recompiled[i]) { + i += 1 +} + +const window = 260 +console.log(`\nfirst divergence at char ${i}:`) +console.log(`\n--- original ---\n${original.slice(Math.max(0, i - window), i + window)}`) +console.log(`\n--- recompiled ---\n${recompiled.slice(Math.max(0, i - window), i + window)}`) +process.exit(1) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.tsx similarity index 68% rename from apps/desktop/src/plugins/hermes-bots/plugin.js rename to apps/desktop/src/plugins/hermes-bots/plugin.tsx index 0dab6d76b71b..5fa61e1b67aa 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.tsx @@ -65,8 +65,6 @@ import { useValue } from '@hermes/plugin-sdk' import { useEffect, useMemo, useRef, useState } from 'react' -import { jsx, jsxs } from 'react/jsx-runtime' - const { McpTab, ToolsetConfigPanel } = sdk // Keep optional exports feature-detected; test harnesses may strip the SDK namespace. const SkillsView = typeof sdk === 'undefined' ? undefined : sdk.SkillsView @@ -83,7 +81,6 @@ 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 - const ID = 'hermes-bots' /** Tree pane id of the Bots home workspace tab (openWorkspace prefixes * `plugin-workspace:`). Tab visibility — not session focus — is what says @@ -132,7 +129,6 @@ const $activityToasts = atom(false) /** Flip the activity-toast pref and persist it. */ function setActivityToasts(enabled) { $activityToasts.set(enabled) - try { Promise.resolve(pluginCtx?.storage?.set?.('activity-toasts', enabled)).catch(() => undefined) } catch { @@ -148,14 +144,12 @@ function setActivityToasts(enabled) { function trackInboundActivity(roster) { const seeding = !watermarksSeeded watermarksSeeded = true - for (const bot of roster) { const key = botSelectionKey(bot) const activity = botActivitySession(bot) const ts = activity?.last_active || 0 const prev = rosterWatermarks.get(key) || 0 rosterWatermarks.set(key, Math.max(prev, ts)) - if (seeding || ts <= prev) { continue } @@ -165,8 +159,10 @@ function trackInboundActivity(roster) { if ($selectedBot.get() === key) { continue } - - $botUnread.set({ ...$botUnread.get(), [key]: true }) + $botUnread.set({ + ...$botUnread.get(), + [key]: true + }) // Roster-hidden bots stay quiet: the unread flag above accumulates // silently (unhiding reveals the badge) but a hidden bot never toasts. @@ -181,7 +177,6 @@ function trackInboundActivity(roster) { const label = displayName(bot, meta) const preview = (activity?.preview || '').trim() const inbound = /^Message from/i.test(preview) - host.notify({ kind: 'info', title: inbound ? `\uD83E\uDD16 New message for ${label}` : `${label} has new activity`, @@ -216,38 +211,36 @@ const BOT_ATTENTION_HINTS = { * timeout) — transient classes must NEVER badge. Pure; tested directly. */ function attentionReasonFromError(errorTextOrReason) { const raw = String(errorTextOrReason || '').trim() - if (!raw) { return null } - if (BOT_ATTENTION_CLASSES.has(raw)) { return raw } - const text = raw.toLowerCase() // Transient failures first, so a retryable error never sticks a badge. - if (/rate.?limit|too many requests|\b429\b|\b5\d\d\b|server error|overloaded|timed?.?out|timeout|temporar/.test(text)) { + if ( + /rate.?limit|too many requests|\b429\b|\b5\d\d\b|server error|overloaded|timed?.?out|timeout|temporar/.test(text) + ) { return null } - if (/no llm provider|no access token|not configured|no api key|missing api key/.test(text)) { return 'missing_config' } - - if (/\b401\b|\b403\b|unauthorized|forbidden|authentication|invalid.?api.?key|credentials? (are )?(invalid|expired)/.test(text)) { + if ( + /\b401\b|\b403\b|unauthorized|forbidden|authentication|invalid.?api.?key|credentials? (are )?(invalid|expired)/.test( + text + ) + ) { return 'provider_auth_or_access' } - if (/quota|out of funds|insufficient (credits?|funds|balance)|payment required|\b402\b|billing/.test(text)) { return 'provider_quota_limit' } - if (/\bblocked\b/.test(text)) { return 'agent_blocked' } - return null } @@ -261,14 +254,18 @@ const $botAttention = atom({}) * classify to null and set nothing. Latest failure wins. */ function noteBotAttention(key, errorTextOrReason) { const reason = attentionReasonFromError(errorTextOrReason) - if (!key || !reason) { return } - $botAttention.set({ ...$botAttention.get(), - [key]: { reason, at: Date.now(), message: String(errorTextOrReason || '').trim().slice(0, 200) } + [key]: { + reason, + at: Date.now(), + message: String(errorTextOrReason || '') + .trim() + .slice(0, 200) + } }) } @@ -277,8 +274,9 @@ function clearBotAttention(key) { if (!key || !$botAttention.get()[key]) { return } - - const next = { ...$botAttention.get() } + const next = { + ...$botAttention.get() + } delete next[key] $botAttention.set(next) } @@ -319,27 +317,22 @@ const $botChatFocused = atom(false) * chat can remain alive behind it, so session focus alone cannot decide which * roster row owns the visible workspace. */ const $botsHomeFronted = atom(false) - let botsHomeClose = null let suppressBotsHomeReopen = false // Latched while a re-front attempt has not yet been answered with visibility. // Cleared the moment the home is actually fronted, and whenever the tab is // retired — a fresh open starts the budget over. See openBotsHomeWorkspace. let botsHomeRefrontTried = false - function saveSelectedRosterBot(bot) { const key = botRosterKey(bot) - $selectedBot.set(botSelectionKey(bot)) $selectedRosterKey.set(key) - try { Promise.resolve(pluginCtx?.storage?.set?.('selected-roster-bot-v1', key)).catch(() => undefined) } catch { /* storage unavailable — selection lasts for this window */ } } - function clearSelectedRosterBot(bot) { clearSelectedRosterKey(botRosterKey(bot)) } @@ -351,9 +344,7 @@ function clearSelectedRosterKey(key) { if ($selectedRosterKey.get() !== key) { return } - $selectedRosterKey.set('') - try { Promise.resolve(pluginCtx?.storage?.set?.('selected-roster-bot-v1', '')).catch(() => undefined) } catch { @@ -366,14 +357,17 @@ function clearSelectedRosterKey(key) { function parseRosterKey(key) { const raw = String(key || '') const at = raw.indexOf('::') - if (at < 0) { - return { connectionId: '', name: '' } + return { + connectionId: '', + name: '' + } + } + return { + connectionId: raw.slice(0, at), + name: raw.slice(at + 2) } - - return { connectionId: raw.slice(0, at), name: raw.slice(at + 2) } } - const $focusedBotProfile = host.state.focusedSessionProfile || host.state.profile /** Profile that owns the chat currently on screen. Bot Mode opens another @@ -382,7 +376,6 @@ const $focusedBotProfile = host.state.focusedSessionProfile || host.state.profil function focusedMentionProfile() { return String($focusedBotProfile.get?.() || '').trim() || 'default' } - function fallbackFocusedBotOwner(profile = $focusedBotProfile.get?.()) { const focusedProfile = String(profile || 'default').trim() || 'default' const activeProfile = String(host.state.profile?.get?.() || 'default').trim() || 'default' @@ -394,41 +387,34 @@ function fallbackFocusedBotOwner(profile = $focusedBotProfile.get?.()) { if (host.state.focusedSessionProfile && focusedProfile !== activeProfile) { return null } - const connectionId = String( host.state.connectionId?.get?.() || - (typeof host.activeConnectionId === 'function' ? host.activeConnectionId() : '') || - '' + (typeof host.activeConnectionId === 'function' ? host.activeConnectionId() : '') || + '' ).trim() - return { authoritative: false, connectionId, profile: focusedProfile } } - const $focusedBotOwner = host.state.focusedSessionOwner || { get: () => fallbackFocusedBotOwner(), listen: listener => { const emit = profile => listener(fallbackFocusedBotOwner(profile)) const unbindProfile = $focusedBotProfile.listen(emit) const unbindConnection = host.state.connectionId?.listen?.(() => emit($focusedBotProfile.get?.())) - return () => { unbindProfile?.() unbindConnection?.() } } } - function focusedRosterOwner(owner) { const name = String(owner?.profile || owner?.name || '').trim() - if (!owner || !name) { return null } - return { authoritative: owner.authoritative !== false, connectionId: String(owner.connectionId || '').trim(), @@ -465,19 +451,27 @@ const $groupClarify = atom({}) // name — stale events under the old key simply have no room to attach to). const GROUP_ACTIVITY_LIMIT = 50 const $groupActivity = atom({}) - function recordGroupActivity(group, event) { const room = $groupChats.get()[group] - if (!room) { return null } - - const current = $groupActivity.get()[group] || { events: [] } - const entry = { at: Date.now(), epoch: room.epoch || 0, ...event } + const current = $groupActivity.get()[group] || { + events: [] + } + const entry = { + at: Date.now(), + epoch: room.epoch || 0, + ...event + } const events = [...current.events, entry].slice(-GROUP_ACTIVITY_LIMIT) - $groupActivity.set({ ...$groupActivity.get(), [group]: { ...current, events } }) - + $groupActivity.set({ + ...$groupActivity.get(), + [group]: { + ...current, + events + } + }) return entry } @@ -493,16 +487,12 @@ function currentGroupActivity(group) { function groupActivityLabel(event) { const kind = event?.kind const base = GROUP_ACTIVITY_LABELS[kind] || kind || 'did something' - if (kind === 'cancelled' || kind === 'settled' || kind === 'capped') { return base } - const who = event?.member === 'You' ? 'You' : groupSpeakerLabel(event?.member || 'A bot') - return `${who} ${base}` } - const GROUP_ACTIVITY_LABELS = { queued: 'sent a message', working: 'is working…', @@ -517,7 +507,6 @@ const GROUP_ACTIVITY_LABELS = { held: 'is held (stopped by you) — @mention it or say resume to release', stopped: 'stopped the room — remaining turns are held until resumed' } - const GROUP_ACTIVITY_GLYPHS = { queued: 'comment', working: 'sync', @@ -539,14 +528,11 @@ function groupActivityTone(kind) { if (kind === 'failed' || kind === 'timed-out') { return 'text-destructive' } - if (kind === 'working' || kind === 'replied' || kind === 'delivered') { return 'text-(--ui-accent,#4f9cf9)' } - return 'text-(--ui-text-tertiary)' } - const GROUP_CHAT_SYNC_META_KEY = 'hermes-bots-groups' // Gateway ui_meta is capped after Python JSON serialization. Keep a healthy // margin below that limit because Python escapes Unicode while JS does not. @@ -570,10 +556,8 @@ let groupChatSyncDisposed = false function groupChatGatewayJsonSize(value) { const json = JSON.stringify(value) let bytes = 0 - for (const character of json) { const codePoint = character.codePointAt(0) - if (codePoint <= 0x7f) { bytes += 1 if (character === ',' || character === ':') { @@ -583,7 +567,6 @@ function groupChatGatewayJsonSize(value) { bytes += codePoint <= 0xffff ? 6 : 12 } } - return bytes } @@ -594,16 +577,18 @@ function groupChatGatewayJsonSize(value) { * rooms (no roomId) fall back to `name:` keys with the older * revision-gated tombstone semantics. */ function groupChatRoomKey(name, room) { - return typeof room?.roomId === 'string' && room.roomId - ? `id:${room.roomId}` - : `name:${String(name)}` + return typeof room?.roomId === 'string' && room.roomId ? `id:${room.roomId}` : `name:${String(name)}` } /** Lift any historical projection shape (v1 wall-clock, v2 name-keyed) to * the v3 room-key shape so one merge path serves mixed-version fleets. */ function normalizeGroupChatSyncSnapshot(snapshot) { if (!snapshot || typeof snapshot !== 'object') { - return { version: 3, rooms: {}, deleted: {} } + return { + version: 3, + rooms: {}, + deleted: {} + } } if (Number(snapshot.version || 0) >= 3) { return { @@ -618,7 +603,10 @@ function normalizeGroupChatSyncSnapshot(snapshot) { if (!room || !Array.isArray(room.log)) { continue } - rooms[`name:${name}`] = { ...room, name } + rooms[`name:${name}`] = { + ...room, + name + } } const deleted = {} for (const [name, at] of Object.entries(snapshot.deleted || {})) { @@ -627,7 +615,12 @@ function normalizeGroupChatSyncSnapshot(snapshot) { // applied before v3). deleted[`name:${name}`] = Number(snapshot.version || 0) >= 2 ? Math.max(0, Number(at || 0)) : 0 } - return { version: 3, updatedAt: Number(snapshot.updatedAt || 0), rooms, deleted } + return { + version: 3, + updatedAt: Number(snapshot.updatedAt || 0), + rooms, + deleted + } } /** Compact, display-oriented copy of Desktop's room log for gateway clients. @@ -654,39 +647,79 @@ function groupChatSyncSnapshot(all = $groupChats.get(), deleted = {}) { version: 3, updatedAt: Date.now(), rooms, - ...(Object.keys(boundedDeleted).length ? { deleted: boundedDeleted } : {}) + ...(Object.keys(boundedDeleted).length + ? { + deleted: boundedDeleted + } + : {}) } - for (const [name, room] of ranked) { const log = room.log.slice(-GROUP_CHAT_SYNC_MESSAGES).map(entry => ({ - ...(entry?.id ? { id: String(entry.id).slice(0, 160) } : {}), + ...(entry?.id + ? { + id: String(entry.id).slice(0, 160) + } + : {}), from: { kind: entry?.from?.kind === 'member' ? 'member' : 'user', name: String(entry?.from?.name || (entry?.from?.kind === 'member' ? 'Bot' : 'You')).slice(0, 128), - ...(entry?.from?.source ? { source: String(entry.from.source).slice(0, 128) } : {}) + ...(entry?.from?.source + ? { + source: String(entry.from.source).slice(0, 128) + } + : {}) }, text: String(entry?.text || '').slice(0, GROUP_CHAT_SYNC_TEXT_CHARS), at: Number(entry?.at || 0), - ...(entry?.thread ? { thread: String(entry.thread).slice(0, 128) } : {}) + ...(entry?.thread + ? { + thread: String(entry.thread).slice(0, 128) + } + : {}) })) const compact = { name: String(name).slice(0, 64), - ...(typeof room?.roomId === 'string' && room.roomId ? { roomId: String(room.roomId).slice(0, 128) } : {}), + ...(typeof room?.roomId === 'string' && room.roomId + ? { + roomId: String(room.roomId).slice(0, 128) + } + : {}), log, revision: Math.max(0, Number(room?.syncRevision ?? room?.revision ?? 0)), members: (Array.isArray(room.members) ? room.members : []).slice(0, GROUP_CHAT_MAX_MEMBERS).map(member => ({ name: String(member?.name || '').slice(0, 128), - ...(member?.handle ? { handle: String(member.handle).slice(0, 128) } : {}), - ...(member?.connectionId ? { connectionId: String(member.connectionId).slice(0, 128) } : {}), - ...(member?.connectionKind ? { connectionKind: String(member.connectionKind).slice(0, 64) } : {}), - ...(member?.connectionLabel ? { connectionLabel: String(member.connectionLabel).slice(0, 128) } : {}), - ...(member?.sourceScoped ? { sourceScoped: true } : {}) + ...(member?.handle + ? { + handle: String(member.handle).slice(0, 128) + } + : {}), + ...(member?.connectionId + ? { + connectionId: String(member.connectionId).slice(0, 128) + } + : {}), + ...(member?.connectionKind + ? { + connectionKind: String(member.connectionKind).slice(0, 64) + } + : {}), + ...(member?.connectionLabel + ? { + connectionLabel: String(member.connectionLabel).slice(0, 128) + } + : {}), + ...(member?.sourceScoped + ? { + sourceScoped: true + } + : {}) })), ...(typeof room?.image === 'string' && room.image.length <= GROUP_CHAT_SYNC_IMAGE_CHARS - ? { image: room.image } + ? { + image: room.image + } : {}) } - const key = groupChatRoomKey(name, room) rooms[key] = compact while (compact.log.length > 1 && groupChatGatewayJsonSize(envelope) > GROUP_CHAT_SYNC_MAX_BYTES) { @@ -699,10 +732,8 @@ function groupChatSyncSnapshot(all = $groupChats.get(), deleted = {}) { delete rooms[key] } } - return envelope } - function groupChatSyncEntryKey(entry) { if (entry?.id) { return `id:${String(entry.id)}` @@ -723,7 +754,6 @@ function groupChatSyncEntryKey(entry) { String(entry?.text || '') ]) } - function groupChatSyncMemberKey(member) { return JSON.stringify([ String(member?.source || ''), @@ -733,7 +763,6 @@ function groupChatSyncMemberKey(member) { String(member?.name || '') ]) } - function groupChatSyncDeletedRevision(source, value) { return Number(source?.version || 0) >= 2 ? Math.max(0, Number(value || 0)) : 0 } @@ -745,11 +774,7 @@ function groupChatSyncDeletedRevision(source, value) { * itself. Gateway revisions order identity/membership/picture and * tombstones; stable message ids make concurrent log union idempotent. * `changedRooms`/`deletedRooms` accept display names or room keys. */ -function mergeGroupChatSyncSnapshots( - remote, - local, - { changedRooms = [], deletedRooms = [], writeRevision = 0 } = {} -) { +function mergeGroupChatSyncSnapshots(remote, local, { changedRooms = [], deletedRooms = [], writeRevision = 0 } = {}) { const remoteNorm = normalizeGroupChatSyncSnapshot(remote) const localNorm = normalizeGroupChatSyncSnapshot(local) const keysFor = (label, norm) => { @@ -791,7 +816,6 @@ function mergeGroupChatSyncSnapshots( deleted[key] = Math.max(Number(deleted[key] || 0), Number(writeRevision || 0)) } } - const rooms = {} const roomKeys = new Set([...Object.keys(remoteNorm.rooms || {}), ...Object.keys(localNorm.rooms || {})]) for (const key of roomKeys) { @@ -832,9 +856,15 @@ function mergeGroupChatSyncSnapshots( image = Object.prototype.hasOwnProperty.call(localRoom || {}, 'image') ? localRoom.image : remoteRoom?.image } rooms[key] = { - ...(identity?.name ? { name: identity.name } : {}), + ...(identity?.name + ? { + name: identity.name + } + : {}), ...(identity?.roomId || (key.startsWith('id:') ? key.slice(3) : '') - ? { roomId: identity?.roomId || key.slice(3) } + ? { + roomId: identity?.roomId || key.slice(3) + } : {}), log: [...entries.values()].sort((left, right) => { const byTime = Number(left?.at || 0) - Number(right?.at || 0) @@ -842,10 +872,13 @@ function mergeGroupChatSyncSnapshots( }), members, revision: Math.max(remoteRevision, localRevision), - ...(typeof image === 'string' && image ? { image } : {}) + ...(typeof image === 'string' && image + ? { + image + } + : {}) } } - for (const [key, deletedRevision] of Object.entries(deleted)) { if (key.startsWith('id:')) { // Tombstones for id-keyed rooms are FINAL: the roomId is minted once @@ -860,7 +893,6 @@ function mergeGroupChatSyncSnapshots( delete deleted[key] } } - return groupChatSyncEnvelope(rooms, deleted) } @@ -875,7 +907,11 @@ function groupChatSyncEnvelope(rooms, deleted = {}) { version: 3, updatedAt: Date.now(), rooms, - ...(Object.keys(boundedDeleted).length ? { deleted: boundedDeleted } : {}) + ...(Object.keys(boundedDeleted).length + ? { + deleted: boundedDeleted + } + : {}) } const ranked = Object.entries(rooms).sort(([, left], [, right]) => { const leftAt = Number(left?.log?.[left.log.length - 1]?.at || 0) @@ -906,7 +942,9 @@ function mergeRemoteGroupChatSnapshotIntoRooms( { preserveRooms = [], deletedRooms = [] } = {} ) { const remoteNorm = normalizeGroupChatSyncSnapshot(remote) - const rooms = { ...(current || {}) } + const rooms = { + ...(current || {}) + } const preserved = new Set(preserveRooms) const locallyDeleted = new Set(deletedRooms) @@ -918,15 +956,17 @@ function mergeRemoteGroupChatSnapshotIntoRooms( localByRoomId.set(room.roomId, name) } } - for (const [key, projected] of Object.entries(remoteNorm.rooms || {})) { if (!projected || !Array.isArray(projected.log)) { continue } const projectedRoomId = projected.roomId || (key.startsWith('id:') ? key.slice(3) : null) - const localName = projectedRoomId && localByRoomId.has(projectedRoomId) - ? localByRoomId.get(projectedRoomId) - : (projected.name && rooms[projected.name] ? projected.name : null) + const localName = + projectedRoomId && localByRoomId.has(projectedRoomId) + ? localByRoomId.get(projectedRoomId) + : projected.name && rooms[projected.name] + ? projected.name + : null const displayName = String(projected.name || localName || (key.startsWith('name:') ? key.slice(5) : key)) if (locallyDeleted.has(displayName) || (localName && locallyDeleted.has(localName))) { // Mid-rename guard: the remote copy may still be under the OLD display @@ -951,7 +991,6 @@ function mergeRemoteGroupChatSnapshotIntoRooms( const members = new Map( (Array.isArray(existing.members) ? existing.members : []).map(member => [groupChatSyncMemberKey(member), member]) ) - for (const entry of projected.log) { const entryKey = groupChatSyncEntryKey(entry) // The projection is COMPACT (truncated text, no images). When the same @@ -968,10 +1007,12 @@ function mergeRemoteGroupChatSnapshotIntoRooms( members.clear() } for (const member of Array.isArray(projected.members) ? projected.members : []) { - members.set(groupChatSyncMemberKey(member), { ...member, remoteSource: true }) + members.set(groupChatSyncMemberKey(member), { + ...member, + remoteSource: true + }) } } - const log = assignLegacyThreads( [...entries.values()].sort((left, right) => { const byTime = Number(left?.at || 0) - Number(right?.at || 0) @@ -983,11 +1024,10 @@ function mergeRemoteGroupChatSnapshotIntoRooms( // A remote rename with a higher revision moves the local record to the // new display name; local views keyed by the old name follow on the // next repaint (roster derives from $groupChats keys). - const targetName = !isPreserved && remoteRevision > localRevision ? displayName : (localName || displayName) + const targetName = !isPreserved && remoteRevision > localRevision ? displayName : localName || displayName if (localName && targetName !== localName) { delete rooms[localName] } - rooms[targetName] = { ...existing, log: bounded.log, @@ -995,7 +1035,11 @@ function mergeRemoteGroupChatSnapshotIntoRooms( sessions: existing.sessions && typeof existing.sessions === 'object' ? existing.sessions : {}, stranded: existing.stranded && typeof existing.stranded === 'object' ? existing.stranded : {}, members: [...members.values()], - ...(projectedRoomId || existing.roomId ? { roomId: existing.roomId || projectedRoomId } : {}), + ...(projectedRoomId || existing.roomId + ? { + roomId: existing.roomId || projectedRoomId + } + : {}), image: isPreserved ? existing.image || null : remoteRevision >= localRevision && Object.prototype.hasOwnProperty.call(projected, 'image') @@ -1006,12 +1050,14 @@ function mergeRemoteGroupChatSnapshotIntoRooms( running: Boolean(existing.running) } } - for (const [key, deletedAt] of Object.entries(remoteNorm.deleted || {})) { const deletedRoomId = key.startsWith('id:') ? key.slice(3) : null - const targetName = deletedRoomId && localByRoomId.has(deletedRoomId) - ? localByRoomId.get(deletedRoomId) - : key.startsWith('name:') ? key.slice(5) : null + const targetName = + deletedRoomId && localByRoomId.has(deletedRoomId) + ? localByRoomId.get(deletedRoomId) + : key.startsWith('name:') + ? key.slice(5) + : null if (!targetName || preserved.has(targetName)) { continue } @@ -1029,13 +1075,10 @@ function mergeRemoteGroupChatSnapshotIntoRooms( for (const name of locallyDeleted) { delete rooms[name] } - return rooms } - function durableGroupChatRooms(all = $groupChats.get()) { const durable = {} - for (const [name, room] of Object.entries(all || {})) { if (!room || !Array.isArray(room.log)) { continue @@ -1063,10 +1106,8 @@ function durableGroupChatRooms(all = $groupChats.get()) { syncRevision: Math.max(0, Number(room.syncRevision || 0)) } } - return durable } - function persistGroupChatRooms(all = $groupChats.get()) { try { return Promise.resolve(pluginCtx?.storage?.set?.('group-chats', durableGroupChatRooms(all))).catch(() => undefined) @@ -1095,18 +1136,12 @@ function markOrphanedGroupMemberDescriptor(member) { sourceReachable: false } } - function groupMemberReferencesConnection(member, connectionId) { const id = String(connectionId || '').trim() - if (!id) { return false } - - return ( - String(member?.connectionId || '').trim() === id || - String(member?.route?.connectionId || '').trim() === id - ) + return String(member?.connectionId || '').trim() === id || String(member?.route?.connectionId || '').trim() === id } /** Register-removed sweep: annotate (not delete) every persisted group-chat @@ -1116,20 +1151,15 @@ function groupMemberReferencesConnection(member, connectionId) { * Returns whether anything changed. */ function sweepGroupChatMembersForRemovedConnection(connectionId) { const id = String(connectionId || '').trim() - if (!id) { return false } - let changed = false - for (const [name, room] of Object.entries($groupChats.get())) { const members = Array.isArray(room?.members) ? room.members : [] - if (!members.some(member => groupMemberReferencesConnection(member, id) && !member?.sourceMissing)) { continue } - changed = true updateGroupChat(name, current => ({ ...current, @@ -1138,7 +1168,6 @@ function sweepGroupChatMembersForRemovedConnection(connectionId) { ) })) } - return changed } @@ -1157,43 +1186,37 @@ function annotateOrphanedGroupChatMembers(rooms, liveConnectionIds = null) { const live = liveConnectionIds && typeof liveConnectionIds.has === 'function' ? liveConnectionIds : null const next = {} let changed = false - for (const [name, room] of Object.entries(rooms || {})) { const members = Array.isArray(room?.members) ? room.members : [] const orphaned = member => { if (!member || member.sourceMissing) { return false } - if (!member.sourceScoped && !member.remoteSource) { return false } - const id = String(member.route?.connectionId || member.connectionId || '').trim() - if (!id) { // Route unresolvable: this is the row shape that threw on render. return true } - return live ? !live.has(id) : false } - if (!members.some(orphaned)) { next[name] = room continue } - changed = true next[name] = { ...room, members: members.map(member => (orphaned(member) ? markOrphanedGroupMemberDescriptor(member) : member)) } } - - return { rooms: next, changed } + return { + rooms: next, + changed + } } - function groupChatSyncConnectionId() { return String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || '') } @@ -1208,21 +1231,20 @@ async function groupChatSyncRequest(job, method, params) { const profile = String(candidate?.targetProfile || candidate?.profile || '') return String(candidate?.connectionId || '') === job.connectionId && profile === 'default' }) - if (route) { return host.requestProfile(route, method, params) } } - const currentConnectionId = groupChatSyncConnectionId() if (job.connectionId && currentConnectionId && job.connectionId !== currentConnectionId) { throw new Error('Group chat gateway changed before sync') } return host.request(method, params) } - async function groupChatRemoteSnapshot(job) { - const result = await groupChatSyncRequest(job, 'profiles.list', { include_sessions: false }) + const result = await groupChatSyncRequest(job, 'profiles.list', { + include_sessions: false + }) const profile = (Array.isArray(result?.profiles) ? result.profiles : []).find(row => row?.name === 'default') const snapshot = profile?.ui_meta?.[GROUP_CHAT_SYNC_META_KEY] const supportsCas = Boolean(profile && Object.prototype.hasOwnProperty.call(profile, 'ui_meta_revisions')) @@ -1236,8 +1258,9 @@ async function groupChatRemoteSnapshot(job) { /** Pull the shared room projection into this Desktop before it publishes any * local state. This is the receive half of the client-only sync contract. */ async function pullGroupChatServerState(connectionId = groupChatSyncConnectionId()) { - const { snapshot: remote } = await groupChatRemoteSnapshot({ connectionId }) - + const { snapshot: remote } = await groupChatRemoteSnapshot({ + connectionId + }) if (!remote) { return false } @@ -1250,12 +1273,10 @@ async function pullGroupChatServerState(connectionId = groupChatSyncConnectionId await persistGroupChatRooms(merged) return true } - function groupChatSyncBackoff(connectionId) { const count = Number(groupChatSyncRetryCounts.get(connectionId) || 0) return Math.min(30000, 1000 * 2 ** Math.min(count, 5)) } - function mergeGroupChatSyncJobs(existing, incoming) { if (!existing || existing.connectionId !== incoming.connectionId) { return incoming @@ -1267,7 +1288,6 @@ function mergeGroupChatSyncJobs(existing, incoming) { deletedRooms: [...new Set([...(existing.deletedRooms || []), ...(incoming.deletedRooms || [])])] } } - function groupChatSyncPayloadEqual(left, right) { return ( JSON.stringify(left?.rooms || {}) === JSON.stringify(right?.rooms || {}) && @@ -1300,7 +1320,6 @@ async function groupChatSyncTargetConnections() { } return [...targets] } - async function flushGroupChatServerSync(connectionId) { if (connectionId === undefined) { // Drain every connection with pending work. @@ -1316,7 +1335,6 @@ async function flushGroupChatServerSync(connectionId) { const job = groupChatSyncPendingByConnection.get(id) groupChatSyncPendingByConnection.delete(id) groupChatSyncInFlightConnections.add(id) - try { const remoteState = await groupChatRemoteSnapshot(job) const local = groupChatSyncSnapshot($groupChats.get()) @@ -1330,7 +1348,11 @@ async function flushGroupChatServerSync(connectionId) { // Reconnect/startup reconciliation often discovers that the gateway // already holds the exact merged projection. Avoid advancing a revision // merely because a view reopened. - if (!(job.changedRooms || []).length && !(job.deletedRooms || []).length && groupChatSyncPayloadEqual(snapshot, remoteState.snapshot)) { + if ( + !(job.changedRooms || []).length && + !(job.deletedRooms || []).length && + groupChatSyncPayloadEqual(snapshot, remoteState.snapshot) + ) { if (remoteState.snapshot) { const pending = groupChatSyncPendingByConnection.get(id) const mergedRooms = mergeRemoteGroupChatSnapshotIntoRooms(remoteState.snapshot, $groupChats.get(), { @@ -1343,16 +1365,18 @@ async function flushGroupChatServerSync(connectionId) { groupChatSyncRetryCounts.delete(id) return } - const configureParams = { name: 'default', - ui_meta: { [GROUP_CHAT_SYNC_META_KEY]: snapshot } + ui_meta: { + [GROUP_CHAT_SYNC_META_KEY]: snapshot + } } if (remoteState.supportsCas) { - configureParams.ui_meta_expected_revisions = { [GROUP_CHAT_SYNC_META_KEY]: remoteState.revision } + configureParams.ui_meta_expected_revisions = { + [GROUP_CHAT_SYNC_META_KEY]: remoteState.revision + } } const result = await groupChatSyncRequest(job, 'profiles.configure', configureParams) - if (result?.applied?.ui_meta !== true) { throw new Error('Gateway rejected group chat ui_meta') } @@ -1362,7 +1386,6 @@ async function flushGroupChatServerSync(connectionId) { ) { throw new Error('Gateway did not advance group chat ui_meta revision') } - const confirmedState = await groupChatRemoteSnapshot(job) if (remoteState.supportsCas && confirmedState.revision < writeRevision) { throw new Error('Group chat ui_meta revision missing after read-back') @@ -1391,10 +1414,13 @@ async function flushGroupChatServerSync(connectionId) { groupChatSyncPendingByConnection.set(id, mergeGroupChatSyncJobs(groupChatSyncPendingByConnection.get(id), job)) groupChatSyncRetryCounts.set(id, retries) if (typeof setTimeout === 'function' && !groupChatSyncRetryTimers.has(id)) { - groupChatSyncRetryTimers.set(id, setTimeout(() => { - groupChatSyncRetryTimers.delete(id) - void flushGroupChatServerSync(id) - }, groupChatSyncBackoff(id))) + groupChatSyncRetryTimers.set( + id, + setTimeout(() => { + groupChatSyncRetryTimers.delete(id) + void flushGroupChatServerSync(id) + }, groupChatSyncBackoff(id)) + ) } } } finally { @@ -1404,7 +1430,6 @@ async function flushGroupChatServerSync(connectionId) { } } } - function stopGroupChatServerSync() { groupChatSyncDisposed = true groupChatSyncPendingByConnection.clear() @@ -1454,12 +1479,15 @@ function scheduleGroupChatServerSync( clearTimeout(retryTimer) groupChatSyncRetryTimers.delete(id) } - groupChatSyncPendingByConnection.set(id, mergeGroupChatSyncJobs(groupChatSyncPendingByConnection.get(id), { - connectionId: id, - allowEmpty, - changedRooms, - deletedRooms - })) + groupChatSyncPendingByConnection.set( + id, + mergeGroupChatSyncJobs(groupChatSyncPendingByConnection.get(id), { + connectionId: id, + allowEmpty, + changedRooms, + deletedRooms + }) + ) } queueFor(activeId) groupChatSyncTimer = setTimeout(() => { @@ -1476,16 +1504,19 @@ function scheduleGroupChatServerSync( .then(() => flushGroupChatServerSync()) }, 350) } - function handleSessionsGatewayTransition() { // A gateway swap invalidates any in-flight room drive: bump every room's // epoch so running loops bail at their next member boundary. - const rooms = { ...$groupChats.get() } - + const rooms = { + ...$groupChats.get() + } for (const name of Object.keys(rooms)) { - rooms[name] = { ...rooms[name], epoch: (rooms[name].epoch || 0) + 1, running: false } + rooms[name] = { + ...rooms[name], + epoch: (rooms[name].epoch || 0) + 1, + running: false + } } - $groupChats.set(rooms) // Pull before re-publishing so a reconnect or source swap never lets this // client's stale cache hide a room written by another Desktop/mobile client. @@ -1546,9 +1577,7 @@ function syncRelayRetention(connections) { if (typeof host.retainProfileSocket !== 'function') { return } - const live = new Set(connections.map(connection => connection.id)) - for (const [id, release] of [...relayRouteRetentions]) { if (!live.has(id)) { relayRouteRetentions.delete(id) @@ -1559,11 +1588,9 @@ function syncRelayRetention(connections) { } } } - if (relayDisposed) { return } - for (const connection of connections) { if (!relayRouteRetentions.has(connection.id)) { relayRouteRetentions.set(connection.id, host.retainProfileSocket(connection.route)) @@ -1580,7 +1607,6 @@ function releaseRelayRetention() { // Disposer from an older shell shape — never break teardown. } } - relayRouteRetentions.clear() } @@ -1589,20 +1615,19 @@ async function relayConnections() { if (typeof host.profileRoutes !== 'function' || typeof host.requestProfile !== 'function') { return [] } - try { const routes = await host.profileRoutes() const byConnection = new Map() - for (const route of Array.isArray(routes) ? routes : []) { const id = String(route?.connectionId || '') - if (id && !byConnection.has(id)) { byConnection.set(id, route) } } - - return [...byConnection.entries()].map(([id, route]) => ({ id, route })) + return [...byConnection.entries()].map(([id, route]) => ({ + id, + route + })) } catch { return [] } @@ -1616,12 +1641,11 @@ async function relayConnections() { * definitively offline → false runtime_offline refusals (#93091 item 2). */ async function relayAgentsOn(connection) { try { - const res = await host.requestProfile(connection.route, 'profiles.list', { include_sessions: false }) + const res = await host.requestProfile(connection.route, 'profiles.list', { + include_sessions: false + }) const profiles = Array.isArray(res?.profiles) ? res.profiles : [] - const label = String( - connection.route?.connectionLabel || connection.route?.label || connection.id - ) - + const label = String(connection.route?.connectionLabel || connection.route?.label || connection.id) return profiles .map(profile => ({ profile: String(profile?.name || ''), @@ -1646,21 +1670,16 @@ async function syncRelayRosters() { if (relayDisposed || relayRosterBusy) { return } - relayRosterBusy = true - try { const connections = await relayConnections() - if (connections.length < 2) { return } - const agentsByConnection = new Map() await Promise.all( connections.map(async connection => { const agents = await relayAgentsOn(connection) - if (agents === null) { // Transient fetch failure: reuse the last good rows for this // connection (or contribute nothing this cycle) so the pushed @@ -1682,19 +1701,18 @@ async function syncRelayRosters() { relayAgentsCache.delete(id) } } - await Promise.all( connections.map(async connection => { const others = [] - for (const [id, agents] of agentsByConnection) { if (id !== connection.id) { others.push(...agents) } } - try { - await host.requestProfile(connection.route, 'bot_relay.roster.sync', { agents: others }) + await host.requestProfile(connection.route, 'bot_relay.roster.sync', { + agents: others + }) } catch { // Older backend without the relay RPCs — skip this connection. } @@ -1712,76 +1730,70 @@ async function drainRelayOutboxes() { if (relayDisposed) { return } - if (relayDrainBusy) { // A push signal raced an in-flight drain. The gateway never re-sends it // (monotone signature), so without this flag the envelope would wait out // the full poll interval — exactly the latency the push path removes. relayDrainRerun = true - return } - relayDrainBusy = true - try { const connections = await relayConnections() // Retention follows the relay-eligible set: with fewer than two // connections there is nothing to relay, so nothing stays pinned. syncRelayRetention(connections.length >= 2 ? connections : []) - if (connections.length < 2) { return } - const byId = new Map(connections.map(connection => [connection.id, connection])) - for (const sender of connections) { let envelopes = [] - try { const res = await host.requestProfile(sender.route, 'bot_relay.outbox.drain', {}) envelopes = Array.isArray(res?.envelopes) ? res.envelopes : [] } catch { continue } - for (const envelope of envelopes) { if (relayDisposed) { return } - const envelopeId = String(envelope?.id || '') const target = byId.get(String(envelope?.target_connection || '')) const postReply = async payload => { try { - await host.requestProfile(sender.route, 'bot_relay.reply', { id: envelopeId, ...payload }) + await host.requestProfile(sender.route, 'bot_relay.reply', { + id: envelopeId, + ...payload + }) } catch { // Sender gateway unreachable — its waiter times out with guidance. } } - if (!envelopeId) { continue } - if (!target) { - await postReply({ error: `connection '${envelope?.target_connection}' is not connected to this Desktop right now` }) + await postReply({ + error: `connection '${envelope?.target_connection}' is not connected to this Desktop right now` + }) continue } // Needs-attention hook (#93091 item 3): a delivered background DM is // this bot's "good turn"; a classified delivery failure badges it. const attentionKey = `${target.id}::${String(envelope?.target_profile || '')}` - try { const res = await host.requestProfile(target.route, 'bot_relay.deliver', { profile: String(envelope?.target_profile || ''), message: String(envelope?.message || '') }) clearBotAttention(attentionKey) - await postReply({ reply: String(res?.reply || '') }) + await postReply({ + reply: String(res?.reply || '') + }) } catch (error) { // #93091: bot_relay.deliver classifies the failed turn and ships the // typed code in the JSON-RPC error's `data.reason`; forward it into @@ -1792,14 +1804,17 @@ async function drainRelayOutboxes() { noteBotAttention(attentionKey, reason || error?.message || error) await postReply({ error: String(error?.message || error || 'delivery failed'), - ...(reason ? { reason } : {}) + ...(reason + ? { + reason + } + : {}) }) } } } } finally { relayDrainBusy = false - if (relayDrainRerun && !relayDisposed) { // Envelopes signaled mid-drain: schedule one follow-up pass (debounced) // instead of leaving them to the interval poll. @@ -1815,17 +1830,14 @@ function scheduleRelayPushDrain() { if (relayDisposed || typeof setTimeout !== 'function') { return } - if (relayPushDebounceTimer !== null) { return } - relayPushDebounceTimer = setTimeout(() => { relayPushDebounceTimer = null void drainRelayOutboxes() }, RELAY_PUSH_DEBOUNCE_MS) } - function startBotRelay() { relayDisposed = false @@ -1834,12 +1846,10 @@ function startBotRelay() { if (typeof setInterval !== 'function' || typeof clearInterval !== 'function') { return } - if (relayRosterTimer === null) { relayRosterTimer = setInterval(() => void syncRelayRosters(), RELAY_ROSTER_INTERVAL_MS) void syncRelayRosters() } - if (relayDrainTimer === null) { relayDrainTimer = setInterval(() => void drainRelayOutboxes(), RELAY_DRAIN_INTERVAL_MS) } @@ -1852,7 +1862,6 @@ function startBotRelay() { relayPushUnsub = host.onEvent('bot_relay.outbox.pending', () => scheduleRelayPushDrain()) } } - function stopBotRelay() { relayDisposed = true // A rerun remembered mid-drain must not leak into the next start — @@ -1861,22 +1870,18 @@ function stopBotRelay() { // Unpin every relay-retained socket (#93594): with the relay stopped the // pooled entries return to dispose-at-refcount-0 semantics. releaseRelayRetention() - if (relayRosterTimer !== null) { clearInterval(relayRosterTimer) relayRosterTimer = null } - if (relayDrainTimer !== null) { clearInterval(relayDrainTimer) relayDrainTimer = null } - if (relayPushDebounceTimer !== null) { clearTimeout(relayPushDebounceTimer) relayPushDebounceTimer = null } - if (relayPushUnsub !== null) { try { relayPushUnsub() @@ -1890,24 +1895,20 @@ function stopBotRelay() { /** Per-bot appearance + display meta, persisted via ctx.storage: * { [botName]: { shape, color, title } } */ const $botMeta = atom({}) - function commitBotMetaV2(storage, snapshot) { const commit = botMetaV2Commit.then(async () => { if (typeof storage?.remove !== 'function' || typeof storage?.set !== 'function') { throw new Error('bot metadata v2 storage is unavailable') } - - const [previousSnapshot, previousMarker] = typeof storage.get === 'function' - ? await Promise.all([ - storage.get(BOT_META_V2_KEY), - storage.get(BOT_META_MIGRATION_KEY) - ]) - : [null, null] - const hasCommittedPrevious = previousMarker === true && + const [previousSnapshot, previousMarker] = + typeof storage.get === 'function' + ? await Promise.all([storage.get(BOT_META_V2_KEY), storage.get(BOT_META_MIGRATION_KEY)]) + : [null, null] + const hasCommittedPrevious = + previousMarker === true && previousSnapshot && typeof previousSnapshot === 'object' && !Array.isArray(previousSnapshot) - try { await storage.remove(BOT_META_MIGRATION_KEY) await storage.set(BOT_META_V2_KEY, snapshot) @@ -1918,65 +1919,72 @@ function commitBotMetaV2(storage, snapshot) { await storage.set(BOT_META_V2_KEY, previousSnapshot) await storage.set(BOT_META_MIGRATION_KEY, true) } catch { - await Promise.allSettled([ - storage.remove(BOT_META_MIGRATION_KEY), - storage.remove(BOT_META_V2_KEY) - ]) + await Promise.allSettled([storage.remove(BOT_META_MIGRATION_KEY), storage.remove(BOT_META_V2_KEY)]) } } else { - await Promise.allSettled( - [BOT_META_MIGRATION_KEY, BOT_META_V2_KEY].map(key => storage.remove(key)) - ) + await Promise.allSettled([BOT_META_MIGRATION_KEY, BOT_META_V2_KEY].map(key => storage.remove(key))) } throw error } }) - botMetaV2Commit = commit.catch(() => undefined) - return commit } - function botOwner(owner) { if (typeof owner === 'string') { const name = owner.trim() const route = migratedLocalRoutes.get(name) - return { - bot: route ? { name, sourceScoped: true, route } : { name }, + bot: route + ? { + name, + sourceScoped: true, + route + } + : { + name + }, name, key: route ? botRouteKey(route) : name, route: route || null } } - const name = String(owner?.name || '').trim() const route = botConnectionRoute(owner) - - return { bot: owner, name, key: route ? botRouteKey(route) : name, route } + return { + bot: owner, + name, + key: route ? botRouteKey(route) : name, + route + } } /** Freshness fence for the server-meta overlay: a roster snapshot fetched * before the latest local/server metadata write must not overwrite it. */ const botMetaWriteAt = new Map() - function noteBotMetaWrite(key) { botMetaWriteAt.set(key, Date.now()) } - async function saveBotMeta(owner, patch) { const { bot, key, name, route } = botOwner(owner) const prevMeta = $botMeta.get()[key] || {} - const next = { ...$botMeta.get(), [key]: { ...prevMeta, ...patch } } + const next = { + ...$botMeta.get(), + [key]: { + ...prevMeta, + ...patch + } + } noteBotMetaWrite(key) $botMeta.set(next) // Local plugin storage: instant, and the fallback for older gateways. let localPersistence = Promise.resolve() try { - const persisted = route || botMetaV2Active - ? commitBotMetaV2(pluginCtx?.storage, next) - : Promise.resolve(pluginCtx?.storage?.set?.(BOT_META_V1_KEY, next)) + const persisted = + route || botMetaV2Active + ? commitBotMetaV2(pluginCtx?.storage, next) + : Promise.resolve(pluginCtx?.storage?.set?.(BOT_META_V1_KEY, next)) localPersistence = persisted.catch(() => undefined) } catch { /* storage unavailable — look persists for this window only */ @@ -1993,8 +2001,19 @@ async function saveBotMeta(owner, patch) { let serverRequest = null try { const { image, pet, ...rest } = next[key] || {} - const request = route ? requestForBot(bot, 'profiles.configure', { name, ui_meta: { 'hermes-bots': rest } }) : - host.request('profiles.configure', { name, ui_meta: { 'hermes-bots': rest } }) + const request = route + ? requestForBot(bot, 'profiles.configure', { + name, + ui_meta: { + 'hermes-bots': rest + } + }) + : host.request('profiles.configure', { + name, + ui_meta: { + 'hermes-bots': rest + } + }) serverRequest = Promise.resolve(request) } catch { /* older/unavailable gateway — the local fallback remains saved */ @@ -2009,12 +2028,28 @@ async function saveBotMeta(owner, patch) { if ('image' in patch && patch.image !== (prevMeta.image ?? null)) { try { const req = patch.image - ? (route - ? requestForBot(bot, 'profiles.set_asset', { name, asset: 'avatar', data: patch.image }) - : host.request('profiles.set_asset', { name, asset: 'avatar', data: patch.image })) - : (route - ? requestForBot(bot, 'profiles.set_asset', { name, asset: 'avatar', clear: true }) - : host.request('profiles.set_asset', { name, asset: 'avatar', clear: true })) + ? route + ? requestForBot(bot, 'profiles.set_asset', { + name, + asset: 'avatar', + data: patch.image + }) + : host.request('profiles.set_asset', { + name, + asset: 'avatar', + data: patch.image + }) + : route + ? requestForBot(bot, 'profiles.set_asset', { + name, + asset: 'avatar', + clear: true + }) + : host.request('profiles.set_asset', { + name, + asset: 'avatar', + clear: true + }) req.catch(() => undefined) } catch { /* older gateway */ @@ -2047,10 +2082,11 @@ async function saveBotMeta(owner, patch) { // just as surely as one fetched before the local write. noteBotMetaWrite(key) } - await localPersistence - - return { serverPersisted: serverOutcome === 'persisted', serverOutcome } + return { + serverPersisted: serverOutcome === 'persisted', + serverOutcome + } } /** Migrate name-keyed appearance state only when the live registry proves @@ -2058,23 +2094,23 @@ async function saveBotMeta(owner, patch) { * multi-source desktop, so the conservative result there is to retain v1 as * rollback data and leave remote rows unpainted. */ function hydrateBotMeta(snapshot, remap = null) { - const next = { ...snapshot } - + const next = { + ...snapshot + } for (const [key, meta] of Object.entries($botMeta.get())) { const target = remap?.get(key) || key - next[target] = { ...(next[target] || {}), ...meta } + next[target] = { + ...(next[target] || {}), + ...meta + } } - $botMeta.set(next) - return next } - async function migrateBotMeta(storage = pluginCtx?.storage) { let v1 = null let v2 = null let v2Committed = false - try { ;[v1, v2, v2Committed] = await Promise.all([ storage?.get?.(BOT_META_V1_KEY), @@ -2084,54 +2120,45 @@ async function migrateBotMeta(storage = pluginCtx?.storage) { } catch { return false } - if (v2Committed === true && v2 && typeof v2 === 'object' && !Array.isArray(v2)) { hydrateBotMeta(v2) botMetaV2Active = true - return true } - if (!v1 || typeof v1 !== 'object' || Array.isArray(v1) || typeof host.agents !== 'function') { if (v1 && typeof v1 === 'object' && !Array.isArray(v1)) { hydrateBotMeta(v1) } - return false } - let union let routes - try { union = await host.agents() routes = typeof host.profileRoutes === 'function' ? await host.profileRoutes() : [] } catch { hydrateBotMeta(v1) - return false } - const sources = Array.isArray(union?.sources) ? union.sources : [] const localAgents = (union?.agents || []).filter(agent => agent?.connectionKind === 'local') - const soleLocal = sources.length === 1 - ? sources[0]?.kind === 'local' - : sources.length === 0 && localAgents.length > 0 && (union?.agents || []).every(agent => agent?.connectionKind === 'local') - + const soleLocal = + sources.length === 1 + ? sources[0]?.kind === 'local' + : sources.length === 0 && + localAgents.length > 0 && + (union?.agents || []).every(agent => agent?.connectionKind === 'local') if (!soleLocal) { hydrateBotMeta(v1) - return false } - const migrated = {} const pendingLocalRoutes = new Map() - for (const [name, meta] of Object.entries(v1)) { - const route = (routes || []).find(candidate => candidate?.mode === 'local' && candidate?.profile === name) || + const route = + (routes || []).find(candidate => candidate?.mode === 'local' && candidate?.profile === name) || (() => { const agent = localAgents.find(candidate => candidate.profile === name) - return agent ? { connectionId: agent.connectionId, @@ -2141,15 +2168,12 @@ async function migrateBotMeta(storage = pluginCtx?.storage) { } : null })() - if (!route?.connectionId) { // A missing route makes the topology proof unusable for this key. Keep // the v1 record intact rather than guessing a local/remote projection. hydrateBotMeta(v1) - return false } - const captured = { connectionId: route.connectionId, mode: 'local', @@ -2159,33 +2183,30 @@ async function migrateBotMeta(storage = pluginCtx?.storage) { migrated[botRouteKey(captured)] = meta pendingLocalRoutes.set(name, captured) } - - const remap = new Map( - [...pendingLocalRoutes].map(([name, route]) => [name, botRouteKey(route)]) - ) - const hydrated = { ...migrated } - + const remap = new Map([...pendingLocalRoutes].map(([name, route]) => [name, botRouteKey(route)])) + const hydrated = { + ...migrated + } for (const [key, meta] of Object.entries($botMeta.get())) { const target = remap.get(key) || key - hydrated[target] = { ...(hydrated[target] || {}), ...meta } + hydrated[target] = { + ...(hydrated[target] || {}), + ...meta + } } - try { await commitBotMetaV2(storage, hydrated) } catch { botMetaV2Active = false hydrateBotMeta(v1) - return false } - migratedLocalRoutes.clear() for (const [name, route] of pendingLocalRoutes) { migratedLocalRoutes.set(name, route) } hydrateBotMeta(hydrated) botMetaV2Active = true - return true } @@ -2195,11 +2216,9 @@ async function migrateBotMeta(storage = pluginCtx?.storage) { /** Session-only view toggle: reveal hidden bots (dimmed) in the roster. */ const $showHiddenBots = atom(false) - function isBotHidden(bot, metaByName) { return Boolean(botRosterMeta(bot, metaByName)?.hidden) } - function isBotPinned(bot, metaByName) { return Boolean(botRosterMeta(bot, metaByName)?.pinned) } @@ -2209,17 +2228,12 @@ function fallbackSelectionAfterHide(name) { if ($selectedBot.get() !== name) { return } - const meta = $botMeta.get() - const visible = $lastRoster - .get() - .filter(bot => botSelectionKey(bot) !== name && !botRosterMeta(bot, meta)?.hidden) - + const visible = $lastRoster.get().filter(bot => botSelectionKey(bot) !== name && !botRosterMeta(bot, meta)?.hidden) if (visible.length) { $selectedBot.set(botSelectionKey(visible[0])) return } - const defaultBot = $lastRoster.get().find(bot => isDefaultBot(bot) && !botRosterMeta(bot, meta)?.hidden) if (defaultBot && botSelectionKey(defaultBot) !== name) { $selectedBot.set(botSelectionKey(defaultBot)) @@ -2245,7 +2259,6 @@ function startHideSweepScheduler(ctx) { let inflight = null let pending = false let disposed = false - const run = () => { timer = null if (disposed) { @@ -2255,7 +2268,6 @@ function startHideSweepScheduler(ctx) { pending = true return } - inflight = Promise.resolve() .then(() => hideOwnedBotSessions()) .catch(() => undefined) @@ -2271,7 +2283,6 @@ function startHideSweepScheduler(ctx) { if (disposed) { return } - try { if (timer !== null) { clearTimeout(timer) @@ -2286,7 +2297,6 @@ function startHideSweepScheduler(ctx) { schedule() } }) - const teardown = () => { disposed = true stopGatewayListener() @@ -2300,7 +2310,6 @@ function startHideSweepScheduler(ctx) { } schedule() } - function hideOwnedBotSessions() { const roomEntries = Object.values($groupChats.get()).flatMap(room => Object.entries(room?.sessions || {}) @@ -2308,26 +2317,33 @@ function hideOwnedBotSessions() { if (!id || id === true) { return null } - const persisted = room?.sessionOwners?.[key] const derived = (room?.members || []).find(member => groupMemberKey(member) === key) // Bare keys are legacy local rooms. A source-qualified key without its // immutable owner is unsafe: never let it fall through ambient routing. - const owner = persisted || derived || (!key.includes('::') ? { name: key } : null) - + const owner = + persisted || + derived || + (!key.includes('::') + ? { + name: key + } + : null) if (key.includes('::')) { const route = owner?.route const sourceMarked = owner?.sourceScoped || owner?.remoteSource - const routeKey = route?.connectionId && route?.profile - ? `${route.connectionId}::${route.profile}` - : '' - + const routeKey = route?.connectionId && route?.profile ? `${route.connectionId}::${route.profile}` : '' if (!sourceMarked || !route?.targetProfile || routeKey !== key) { return null } } - - return owner ? { owner, id, dedupe: `${key}\u0000${id}` } : null + return owner + ? { + owner, + id, + dedupe: `${key}\u0000${id}` + } + : null }) .filter(Boolean) ) @@ -2335,13 +2351,7 @@ function hideOwnedBotSessions() { // The same member session can appear in several rooms (and legacy rooms can // share ids) — hide each (owner, id) pair exactly once. const rooms = [...new Map(roomEntries.map(entry => [entry.dedupe, entry])).values()] - - const known = Promise.all( - rooms.map(({ owner, id }) => - hidePersistedBotSession(owner, id).catch(() => undefined) - ) - ) - + const known = Promise.all(rooms.map(({ owner, id }) => hidePersistedBotSession(owner, id).catch(() => undefined))) return Promise.all([known, sweepBotProfileSessions().catch(() => undefined)]) } @@ -2352,12 +2362,16 @@ function hidePersistedBotSession(bot, sessionId, profileOverride = '') { if (typeof host.setPersistedSessionHidden !== 'function') { return Promise.resolve() } - const route = botConnectionRoute(bot) const fallback = String(bot?.name || '').trim() || 'default' const profile = profileOverride || backendTargetProfile(route, fallback) - - return Promise.resolve(host.setPersistedSessionHidden(route, { sessionId, profile, hidden: true })) + return Promise.resolve( + host.setPersistedSessionHidden(route, { + sessionId, + profile, + hidden: true + }) + ) } // Titles Bot Mode itself mints for its plumbing sessions. Bot-to-bot CLI @@ -2369,12 +2383,10 @@ function hidePersistedBotSession(bot, sessionId, profileOverride = '') { // user gave it and is never touched. const BOT_MODE_SWEEP_TITLES = new Set(['Bot Chat', 'Agent Inbox']) const BOT_MODE_SWEEP_MIN_AGE_SECONDS = 5 * 60 - function isBotModeSweepTitle(title) { const t = String(title || '').trim() return BOT_MODE_SWEEP_TITLES.has(t) || t.startsWith('Group: ') } - function isBotModeSweepCandidate(row, nowSeconds = Date.now() / 1000) { const startedAt = Number(row?.started_at) return ( @@ -2408,45 +2420,40 @@ async function sweepBotProfileSessions(nowSeconds = Date.now() / 1000) { if (typeof host.listPersistedSessions !== 'function' || typeof host.setPersistedSessionHidden !== 'function') { return } - const cached = $lastRoster.get() let roster = Array.isArray(cached) && cached.length ? cached : null - if (!roster) { // Plugin load can run before the Bots pane hydrates $lastRoster — fall // back to the active gateway's own profile list (local bots; remote // sources get covered by the next sweep once the roster cache exists). try { - const activeBot = { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } + const activeBot = { + name: String(host.state.profile?.get?.() || 'default').trim() || 'default' + } const res = await requestForBot(activeBot, 'profiles.list', {}) roster = Array.isArray(res?.profiles) ? res.profiles : [] } catch { return } } - await Promise.all( roster.map(async bot => { const name = String(bot?.name || '').trim() - if (!name) { return } - try { const route = botConnectionRoute(bot) const profile = backendTargetProfile(route, name) - const res = await host.listPersistedSessions(route, { profile, limit: PROFILE_SESSION_LIST_LIMIT }) + const res = await host.listPersistedSessions(route, { + profile, + limit: PROFILE_SESSION_LIST_LIMIT + }) const rows = Array.isArray(res?.sessions) ? res.sessions : [] - await Promise.all( rows .filter(row => isBotModeSweepCandidate(row, nowSeconds)) - .map(row => - Promise.resolve( - hidePersistedBotSession(bot, row.id, profile) - ).catch(() => undefined) - ) + .map(row => Promise.resolve(hidePersistedBotSession(bot, row.id, profile)).catch(() => undefined)) ) } catch { /* older gateway / unreachable source — leave this profile alone */ @@ -2458,7 +2465,6 @@ async function sweepBotProfileSessions(nowSeconds = Date.now() / 1000) { /** Fetch server-side avatars for roster rows flagged has_avatar when the * local cache doesn't already have an image for them. Fire-and-forget. */ const avatarFetchInflight = new Set() - const avatarPushInflight = new Set() /** Backfill: local meta has art the server lacks -> profiles.set_asset. @@ -2467,20 +2473,29 @@ const avatarPushInflight = new Set() function pushLocalAvatars(roster) { for (const bot of roster) { const key = botMetaKey(bot) - if (bot.has_avatar || avatarPushInflight.has(key)) { continue } - const image = $botMeta.get()[key]?.image - if (image && typeof image === 'string' && image.startsWith('data:')) { avatarPushInflight.add(key) const request = bot.sourceScoped - ? requestForBot(bot, 'profiles.set_asset', { name: bot.name, asset: 'avatar', data: image }) - : host.request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: image }) + ? requestForBot(bot, 'profiles.set_asset', { + name: bot.name, + asset: 'avatar', + data: image + }) + : host.request('profiles.set_asset', { + name: bot.name, + asset: 'avatar', + data: image + }) Promise.resolve(request) - .then(() => queryClient.invalidateQueries({ queryKey: ['hermes-bots', 'roster'] })) + .then(() => + queryClient.invalidateQueries({ + queryKey: ['hermes-bots', 'roster'] + }) + ) .catch(() => avatarPushInflight.delete(key)) continue } @@ -2489,19 +2504,29 @@ function pushLocalAvatars(roster) { // live SVG (tagged data-bot-face) to a PNG and push that, so the // inter-agent notices (core #85855/#85888) can show the real pfp. const svg = document.querySelector('svg[data-bot-face=' + JSON.stringify(bot.name) + ']') - if (!svg) { continue } - avatarPushInflight.add(key) rasterizeSvgToPng(svg, 160) .then(png => png ? (bot.sourceScoped - ? requestForBot(bot, 'profiles.set_asset', { name: bot.name, asset: 'avatar', data: png }) - : host.request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: png })) - .then(() => queryClient.invalidateQueries({ queryKey: ['hermes-bots', 'roster'] })) + ? requestForBot(bot, 'profiles.set_asset', { + name: bot.name, + asset: 'avatar', + data: png + }) + : host.request('profiles.set_asset', { + name: bot.name, + asset: 'avatar', + data: png + }) + ).then(() => + queryClient.invalidateQueries({ + queryKey: ['hermes-bots', 'roster'] + }) + ) : Promise.reject(new Error('rasterize failed')) ) .catch(() => avatarPushInflight.delete(key)) @@ -2519,7 +2544,6 @@ function rasterizeSvgToPng(svgEl, size) { const markup = new XMLSerializer().serializeToString(clone) const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup) const img = new Image() - img.onload = () => { try { const canvas = document.createElement('canvas') @@ -2545,7 +2569,6 @@ function isBackfilledFacePng(dataUrl) { if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) { return false } - try { const bin = atob(dataUrl.slice('data:image/png;base64,'.length).slice(0, 48)) if (bin.length < 24) { @@ -2558,25 +2581,26 @@ function isBackfilledFacePng(dataUrl) { return false } } - function pullServerAvatars(roster) { pushLocalAvatars(roster) - for (const bot of roster) { const key = botMetaKey(bot) - if (!bot.has_avatar || avatarFetchInflight.has(key)) { continue } - if ($botMeta.get()[key]?.image) { continue } - avatarFetchInflight.add(key) const assetRequest = bot.sourceScoped - ? requestForBot(bot, 'profiles.get_asset', { name: bot.name, asset: 'avatar' }) - : host.request('profiles.get_asset', { name: bot.name, asset: 'avatar' }) + ? requestForBot(bot, 'profiles.get_asset', { + name: bot.name, + asset: 'avatar' + }) + : host.request('profiles.get_asset', { + name: bot.name, + asset: 'avatar' + }) Promise.resolve(assetRequest) .then(res => { if (res?.found && res.data) { @@ -2587,7 +2611,13 @@ function pullServerAvatars(roster) { if (isBackfilledFacePng(res.data) && mine.imageKind !== 'photo' && !mine.pet) { return } - $botMeta.set({ ...current, [key]: { ...mine, image: res.data } }) + $botMeta.set({ + ...current, + [key]: { + ...mine, + image: res.data + } + }) persistBotMetaSnapshot($botMeta.get(), Boolean(bot.sourceScoped)) } }) @@ -2613,8 +2643,9 @@ function pullServerAvatars(roster) { function mergeServerMeta(roster, fetchedAt = 0) { const local = $botMeta.get() let changed = false - const next = { ...local } - + const next = { + ...local + } for (const bot of roster) { const server = bot.ui_meta?.['hermes-bots'] if (server && typeof server === 'object') { @@ -2623,7 +2654,10 @@ function mergeServerMeta(roster, fetchedAt = 0) { continue } const mine = next[key] || {} - const merged = { ...mine, ...server } + const merged = { + ...mine, + ...server + } // Local-only fields survive the server overlay. if (mine.image) { @@ -2646,21 +2680,22 @@ function mergeServerMeta(roster, fetchedAt = 0) { ) { delete merged.group } - if (JSON.stringify(next[key] || null) !== JSON.stringify(merged)) { next[key] = merged changed = true } } } - if (changed) { $botMeta.set(next) // Persist server reconciliation so a relaunch cannot rehydrate stale // local fields that the server intentionally removed. try { - persistBotMetaSnapshot(next, roster.some(bot => bot.sourceScoped)) + persistBotMetaSnapshot( + next, + roster.some(bot => bot.sourceScoped) + ) } catch { /* storage unavailable — reconciliation lasts for this window only */ } @@ -2681,16 +2716,18 @@ async function duplicateBot(bot, roster) { // base forever (#19). const suffix = `-${n}` const candidate = base.slice(0, 64 - suffix.length) + suffix - if (!roster.some(b => b.name === candidate && (!ownerKey || botMetaKey(b)?.startsWith(`${ownerRoute.connectionId}::`)))) { + if ( + !roster.some( + b => b.name === candidate && (!ownerKey || botMetaKey(b)?.startsWith(`${ownerRoute.connectionId}::`)) + ) + ) { name = candidate break } } - if (!name) { throw new Error('No free name for the duplicate.') } - await requestForBot(bot, 'profiles.create', { name, clone_from: base, @@ -2703,12 +2740,19 @@ async function duplicateBot(bot, roster) { const meta = $botMeta.get()[botMetaKey(bot)] if (meta) { const { chat, created, ...look } = meta - await saveBotMeta({ ...bot, name, route: ownerRoute, sourceScoped: Boolean(ownerRoute) }, { - ...look, - title: meta.title ? `${meta.title} (copy)` : '' - }) + await saveBotMeta( + { + ...bot, + name, + route: ownerRoute, + sourceScoped: Boolean(ownerRoute) + }, + { + ...look, + title: meta.title ? `${meta.title} (copy)` : '' + } + ) } - return name } @@ -2725,11 +2769,9 @@ async function duplicateBot(bot, roster) { * directory (hermes-agent#52279). That is the "can't delete a bot" error. */ async function deleteBot(bot) { const route = botConnectionRoute(bot) - if (isDefaultBot(bot) || String(route?.targetProfile || '').toLowerCase() === 'default') { throw new Error('The default profile cannot be deleted.') } - if (typeof host.deleteProfile === 'function') { if (route) { await host.deleteProfile(route) @@ -2741,20 +2783,18 @@ async function deleteBot(bot) { if (route) { throw new Error('Source-scoped profile deletion requires host.deleteProfile.') } - const result = await host.request('cli.exec', { argv: ['profile', 'delete', bot.name, '--yes'] }) - if (result?.blocked || result?.code !== 0) { throw new Error(result?.hint || result?.output || `Could not delete profile ${bot.name}.`) } } - - const meta = { ...$botMeta.get() } + const meta = { + ...$botMeta.get() + } delete meta[botMetaKey(bot)] $botMeta.set(meta) - try { if (route) { await commitBotMetaV2(pluginCtx?.storage, meta) @@ -2764,31 +2804,29 @@ async function deleteBot(bot) { } catch { /* profile is deleted; stale local appearance is harmless if storage fails */ } - - const unread = { ...$botUnread.get() } + const unread = { + ...$botUnread.get() + } delete unread[botSelectionKey(bot)] $botUnread.set(unread) rosterWatermarks.delete(botSelectionKey(bot)) avatarFetchInflight.delete(botMetaKey(bot)) avatarPushInflight.delete(botMetaKey(bot)) - if ($selectedBot.get() === botSelectionKey(bot)) { $selectedBot.set('default') } clearSelectedRosterBot(bot) - if ($openBotChat.get()?.key === botRosterKey(bot)) { $openBotChat.set(null) syncBotsHomeWorkspace() } - - queryClient.invalidateQueries({ queryKey: ROSTER_KEY }) - + queryClient.invalidateQueries({ + queryKey: ROSTER_KEY + }) const activeOwner = focusedRosterOwner($focusedBotOwner.get?.()) const deletedOwnerIsActive = route ? activeOwner?.connectionId === route.connectionId && activeOwner?.name === route.profile : activeOwner?.name === bot.name - if (deletedOwnerIsActive && typeof host.newChat === 'function') { host.newChat('default') } @@ -2820,7 +2858,6 @@ if (typeof document !== 'undefined' && !document.getElementById('hermes-bots-ros '.hermes-bots-pulse { animation: hermes-bots-pulse 1.2s ease-in-out infinite; }' document.head.appendChild(style) } - const AVATAR_SHAPES = ['circle', 'squircle', 'pill', 'triangle', 'hexagon', 'cloud', 'drop'] const AVATAR_PICKER_SHAPES = ['circle', 'blob', 'squircle', 'pill', 'triangle', 'hexagon', 'cloud', 'drop'] @@ -2851,13 +2888,11 @@ function sigilGeometry(name, seed) { const gy = j => 8 + j * 6 // 5 rows: 8..32 const strokes = [] const segments = 4 + Math.floor(rng() * 3) - for (let k = 0; k < segments; k++) { const x1 = Math.floor(rng() * 3) // left half incl. center const y1 = Math.floor(rng() * 5) const x2 = Math.min(2, Math.max(0, x1 + (rng() > 0.5 ? 1 : -1))) const y2 = Math.min(4, Math.max(0, y1 + Math.floor(rng() * 3) - 1)) - strokes.push(`M${gx(x1)} ${gy(y1)} L${gx(x2)} ${gy(y2)}`) // mirror (col i → col 4-i) strokes.push(`M${gx(4 - x1)} ${gy(y1)} L${gx(4 - x2)} ${gy(y2)}`) @@ -2870,21 +2905,31 @@ function sigilGeometry(name, seed) { // spine down the axis grounds every variant strokes.push(`M20 ${gy(0)} L20 ${gy(4)}`) - const ring = rng() > 0.45 ? 'M20 4 L36 20 L20 36 L4 20 Z' : null - return { strokes: strokes.join(' '), ring } + return { + strokes: strokes.join(' '), + ring + } } - const AVATAR_COLORS = [ - '#f5f5f4', // white - '#8d6748', // brown - '#ef4444', // red - '#f97316', // orange - '#14b8a6', // teal - '#38bdf8', // cyan - '#3b40c8', // royal blue - '#8b5cf6', // violet - '#ec4899', // magenta + '#f5f5f4', + // white + '#8d6748', + // brown + '#ef4444', + // red + '#f97316', + // orange + '#14b8a6', + // teal + '#38bdf8', + // cyan + '#3b40c8', + // royal blue + '#8b5cf6', + // violet + '#ec4899', + // magenta '#9ca3af' // silver ] @@ -2900,7 +2945,6 @@ function isDarkColor(hex) { return false } } - function defaultShapeFor(name) { let hash = 0 for (const ch of name) { @@ -2926,21 +2970,30 @@ const BLOB_KINDS = ['round', 'organic', 'boxy', 'capsule', 'nub', 'cloud', 'drop // frozen per blobatar major (gen2: 0.22 / 0.48 / 0.60 / 0.70 / 0.79 / 0.86 / // 0.915 / 0.95 / 0.98). const BLOB_KIND_TRAIT = { - round: 0.11, organic: 0.35, boxy: 0.54, capsule: 0.65, nub: 0.745, - cloud: 0.825, droplet: 0.8875, hexagon: 0.9325, sun: 0.965, triangle: 0.99 + round: 0.11, + organic: 0.35, + boxy: 0.54, + capsule: 0.65, + nub: 0.745, + cloud: 0.825, + droplet: 0.8875, + hexagon: 0.9325, + sun: 0.965, + triangle: 0.99 } - 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 } + return { + seed: seedPart || name || 'agent', + seedPart, + kind + } } - function blobShapeString(seedPart, kind) { if (kind) { return `blobatar:${seedPart}:${kind}` @@ -2954,14 +3007,15 @@ function blobMarkup(shape, name, size) { if (!blobatarSvg) { return null } - const { seed, kind } = parseBlobShape(shape, name) - const opts = { size } - + const opts = { + size + } if (kind) { - opts.traits = { shape: BLOB_KIND_TRAIT[kind] } + opts.traits = { + shape: BLOB_KIND_TRAIT[kind] + } } - try { return blobatarSvg(seed, opts).replace(' + {ring ? : null} + + + ) + } + const stroke = { + fill: color, + stroke: color, + strokeWidth: 7, + strokeLinejoin: 'round' + } + const edge = { + fill: 'none', + stroke: 'rgba(0,0,0,0.4)', + strokeWidth: 1.4, + strokeLinejoin: 'round', + strokeLinecap: 'round' + } + const face = { + fill: color, + stroke: 'rgba(0,0,0,0.4)', + strokeWidth: 1.4, + strokeLinejoin: 'round' } - - const stroke = { fill: color, stroke: color, strokeWidth: 7, strokeLinejoin: 'round' } - const edge = { fill: 'none', stroke: 'rgba(0,0,0,0.4)', strokeWidth: 1.4, strokeLinejoin: 'round', strokeLinecap: 'round' } - const face = { fill: color, stroke: 'rgba(0,0,0,0.4)', strokeWidth: 1.4, strokeLinejoin: 'round' } - switch (shape) { // ── platonic solids ── case 'tetrahedron': - return jsxs('g', { - children: [ - jsx('path', { d: 'M20 5 L36 33 L4 33 Z', ...face }), - jsx('path', { d: 'M20 5 L20 25 M4 33 L20 25 M36 33 L20 25', ...edge }) - ] - }) + return ( + + + + + ) case 'cube': - return jsxs('g', { - children: [ - jsx('path', { d: 'M20 4 L33 11 L33 29 L20 36 L7 29 L7 11 Z', ...face }), - jsx('path', { d: 'M7 11 L20 18 L33 11 M20 18 L20 36', ...edge }) - ] - }) + return ( + + + + + ) case 'octahedron': - return jsxs('g', { - children: [ - jsx('path', { d: 'M20 3 L36 20 L20 37 L4 20 Z', ...face }), - jsx('path', { d: 'M4 20 L36 20 M20 3 L20 37', ...edge }) - ] - }) + return ( + + + + + ) case 'dodecahedron': - return jsxs('g', { - children: [ - jsx('path', { - d: 'M20 3 L30 6.2 L36.2 14.7 L36.2 25.3 L30 33.8 L20 37 L10 33.8 L3.8 25.3 L3.8 14.7 L10 6.2 Z', - ...face - }), - jsx('path', { - d: + return ( + + + + + ) case 'icosahedron': - return jsxs('g', { - children: [ - jsx('path', { d: 'M20 3 L34.7 11.5 L34.7 28.5 L20 37 L5.3 28.5 L5.3 11.5 Z', ...face }), - jsx('path', { - d: + return ( + + + + + ) // ── legacy flat shapes (stored picks from earlier versions) ── case 'squircle': - return jsx('rect', { x: 3, y: 3, width: 34, height: 34, rx: 11, fill: color }) + return case 'pill': - return jsx('rect', { x: 2, y: 7, width: 36, height: 26, rx: 13, fill: color }) + return case 'triangle': - return jsx('path', { d: 'M20 5.5 L36 33.5 L4 33.5 Z', ...stroke }) + return case 'hexagon': - return jsx('path', { d: 'M20 3.5 L34.5 11.75 L34.5 28.25 L20 36.5 L5.5 28.25 L5.5 11.75 Z', ...stroke }) + return case 'cloud': - return jsx('path', { - d: 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z', - fill: color - }) + return case 'drop': - return jsx('path', { d: 'M20 3 C20 3 6 20 6 27 a14 13.5 0 0 0 28 0 C34 20 20 3 20 3 Z', fill: color }) + return default: - return jsx('circle', { cx: 20, cy: 20, r: 17.5, fill: color }) + return } } - const EYE_Y = { // solids: eyes sit on the upper face region, clear of the busiest edges tetrahedron: 26, @@ -3088,7 +3160,6 @@ const EYE_X = { dodecahedron: [16.5, 23.5], icosahedron: [16.5, 23.5] } - function cubicAt(p0, p1, p2, p3, t) { const u = 1 - t return [ @@ -3101,23 +3172,18 @@ function cubicAt(p0, p1, p2, p3, t) { function sampleDropRing(steps) { const pts = [] const n = Math.max(8, Math.floor(steps / 3)) - for (let i = 0; i < n; i++) { pts.push(cubicAt([20, 3], [20, 3], [6, 20], [6, 27], i / n)) } - for (let i = 0; i <= n; i++) { const t = (i / n) * Math.PI pts.push([20 - 14 * Math.cos(t), 27 + 13.5 * Math.sin(t)]) } - for (let i = 1; i <= n; i++) { pts.push(cubicAt([34, 27], [34, 20], [20, 3], [20, 3], i / n)) } - return pts } - function svgArc(x1, y1, rx, ry, fa, fs, x2, y2) { const dx = (x1 - x2) / 2 const dy = (y1 - y2) / 2 @@ -3137,8 +3203,8 @@ function svgArc(x1, y1, rx, ry, fa, fs, x2, y2) { if (fa === fs) { sq = -sq } - const cx = sq * (rx * dy / ry) + (x1 + x2) / 2 - const cy = sq * (-ry * dx / rx) + (y1 + y2) / 2 + const cx = sq * ((rx * dy) / ry) + (x1 + x2) / 2 + const cy = sq * ((-ry * dx) / rx) + (y1 + y2) / 2 const ang = (ux, uy, vx, vy) => { const n = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1 let a = Math.acos(Math.max(-1, Math.min(1, (ux * vx + uy * vy) / n))) @@ -3155,9 +3221,15 @@ function svgArc(x1, y1, rx, ry, fa, fs, x2, y2) { if (fs && dtheta < 0) { dtheta += Math.PI * 2 } - return { cx, cy, rx, ry, theta1, dtheta } + return { + cx, + cy, + rx, + ry, + theta1, + dtheta + } } - function sampleArc(arc, n) { const pts = [] for (let i = 0; i < n; i++) { @@ -3178,9 +3250,9 @@ function sampleCloudRing(steps) { const len4 = 19 const total = len1 + len2 + len3 + len4 const n = Math.max(64, steps) - const n1 = Math.max(8, Math.round(n * len1 / total)) - const n2 = Math.max(10, Math.round(n * len2 / total)) - const n3 = Math.max(10, Math.round(n * len3 / total)) + const n1 = Math.max(8, Math.round((n * len1) / total)) + const n2 = Math.max(10, Math.round((n * len2) / total)) + const n3 = Math.max(10, Math.round((n * len3) / total)) const n4 = Math.max(4, n - n1 - n2 - n3) const pts = [] pts.push(...sampleArc(a1, n1)) @@ -3197,7 +3269,6 @@ function sampleCloudRing(steps) { * a dumped point cloud. */ function sampleFaceRing(shape, steps = 52) { const kind = (shape || '').startsWith('sigil-') ? 'circle' : shape - if (kind === 'drop' || kind === 'teardrop') { return sampleDropRing(steps) } @@ -3205,7 +3276,6 @@ function sampleFaceRing(shape, steps = 52) { return sampleCloudRing(steps) } const pts = [] - for (let i = 0; i < steps; i++) { const a = (i / steps) * Math.PI * 2 - Math.PI / 2 const c = Math.cos(a) @@ -3225,7 +3295,7 @@ function sampleFaceRing(shape, steps = 52) { rx = ry = 16 / d } else if (kind === 'triangle' || kind === 'tetrahedron' || kind === 'wedge') { const u = (a + Math.PI / 2 + Math.PI * 2) % (Math.PI * 2) - const sector = (u / (Math.PI * 2 / 3)) % 1 + const sector = (u / ((Math.PI * 2) / 3)) % 1 rx = ry = 13.5 / Math.max(0.42, Math.cos((sector - 0.5) * 1.9)) } else if (kind === 'hexagon' || kind === 'hex' || kind === 'icosahedron' || kind === 'dodecahedron') { const seg = Math.PI / 3 @@ -3241,13 +3311,10 @@ function sampleFaceRing(shape, steps = 52) { } else { rx = ry = 16.2 } - pts.push([20 + rx * c, 20 + ry * s]) } - return pts } - function projectFacePoint(x, y, turn, tilt, roll) { const dx = x - 20 const dy = y - 20 @@ -3258,18 +3325,14 @@ function projectFacePoint(x, y, turn, tilt, roll) { const sy = 0.8 + 0.2 * Math.abs(Math.cos((tilt * Math.PI) / 180)) return [20 + xr * sx, 20 + yr * sy] } - function ringToPath(pts) { if (!pts.length) { return '' } - let d = `M${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)}` - for (let i = 1; i < pts.length; i++) { d += `L${pts[i][0].toFixed(2)} ${pts[i][1].toFixed(2)}` } - return d + 'Z' } @@ -3288,7 +3351,6 @@ function facePose(mood, t) { d2: 0.2 + 0.8 * Math.max(0, Math.sin(t * 2.6 - 1.4)) } } - return { turn: Math.sin(t * 0.5) * 1.5, tilt: Math.sin(t * 0.27), @@ -3301,7 +3363,6 @@ function facePose(mood, t) { d2: 0 } } - function paintMathFace(svg, t) { const mood = svg.getAttribute('data-hb-mood') || 'idle' const shape = svg.getAttribute('data-hb-shape') || 'circle' @@ -3312,7 +3373,6 @@ function paintMathFace(svg, t) { const el = svg.querySelector('[data-hb-el]') const er = svg.querySelector('[data-hb-er]') const dots = svg.querySelectorAll('[data-hb-dot]') - if (body) { if (shape === 'cloud') { body.setAttribute('d', 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z') @@ -3321,16 +3381,13 @@ function paintMathFace(svg, t) { body.setAttribute('d', ringToPath(ring)) } } - const eyeY = (shape === 'cloud' ? 22 : 17.2) + pose.gazeY const eyeL = 15.4 + pose.gazeX const eyeR = 24.6 + pose.gazeX - if (el) { el.setAttribute('cx', eyeL) el.setAttribute('cy', eyeY) } - if (er) { er.setAttribute('cx', eyeR) er.setAttribute('cy', eyeY) @@ -3341,40 +3398,35 @@ function paintMathFace(svg, t) { // lower-set eyes. const hl = svg.querySelector('[data-hb-hl-l]') const hr = svg.querySelector('[data-hb-hl-r]') - if (hl) { hl.setAttribute('cx', eyeL - 0.6) hl.setAttribute('cy', eyeY - 0.7) } - if (hr) { hr.setAttribute('cx', eyeR - 0.6) hr.setAttribute('cy', eyeY - 0.7) } - if (open) { open.setAttribute('opacity', pose.blink ? '0' : '1') } - if (shut) { - shut.setAttribute('d', `M${eyeL - 2.6} ${eyeY} L${eyeL + 2.6} ${eyeY} M${eyeR - 2.6} ${eyeY} L${eyeR + 2.6} ${eyeY}`) + shut.setAttribute( + 'd', + `M${eyeL - 2.6} ${eyeY} L${eyeL + 2.6} ${eyeY} M${eyeR - 2.6} ${eyeY} L${eyeR + 2.6} ${eyeY}` + ) shut.setAttribute('opacity', pose.blink ? '1' : '0') } - dots.forEach((dot, i) => { const o = i === 0 ? pose.d0 : i === 1 ? pose.d1 : pose.d2 dot.setAttribute('opacity', String(o)) }) - svg.style.transform = `rotate(${pose.tilt}deg)` svg.style.transformOrigin = '50% 70%' } - function walkMathFaces(root, acc) { if (!root || !root.querySelectorAll) { return acc } - root.querySelectorAll('svg[data-hb-math]').forEach(node => acc.push(node)) root.querySelectorAll('*').forEach(el => { if (el.shadowRoot) { @@ -3383,20 +3435,16 @@ function walkMathFaces(root, acc) { }) return acc } - function startFaceClock() { if (typeof window === 'undefined') { return } - if (window.__hbFaceClock) { // Already initialized (possibly parked) — make sure it's awake. BotFace // renders route here, so a face mounting is what wakes a dormant clock. window.__hbFaceClock.wake() - return } - const t0 = performance.now() // A large roster can mount hundreds of faces. Observe the cached nodes so // off-screen cards do not consume a full animation frame by themselves. @@ -3408,7 +3456,6 @@ function startFaceClock() { typeof IntersectionObserver === 'function' ? new IntersectionObserver(entries => { let becameVisible = false - for (const entry of entries) { if (entry.isIntersecting) { visibleFaces.add(entry.target) @@ -3424,16 +3471,12 @@ function startFaceClock() { } }) : null - const scanFaces = () => { faces = walkMathFaces(document, []) - if (!observer) { return } - const currentFaces = new Set(faces) - for (const svg of observedFaces) { if (!currentFaces.has(svg)) { observer.unobserve(svg) @@ -3441,7 +3484,6 @@ function startFaceClock() { visibleFaces.delete(svg) } } - for (const svg of faces) { if (!observedFaces.has(svg)) { observedFaces.add(svg) @@ -3459,7 +3501,6 @@ function startFaceClock() { } const t = (now - t0) / 1000 const facesToPaint = observer ? visibleFaces : faces - for (const svg of facesToPaint) { if (svg.isConnected) { paintMathFace(svg, t) @@ -3470,12 +3511,10 @@ function startFaceClock() { // Nothing worth animating: no faces mounted (BotFace wakes us on the next // mount) or none visible (the observer wakes us when one scrolls in). const idle = () => faces.length === 0 || (observer && visibleFaces.size === 0) - const teardownCaches = () => { if (observer) { observer.disconnect() } - visibleFaces.clear() observedFaces.clear() faces = [] @@ -3486,8 +3525,10 @@ function startFaceClock() { // hidden/minimized/unfocused pause, dormancy, teardown). typeof-guarded so // older shells and the vm test harness use the hand-rolled path below. if (typeof createBudgetedLoop === 'function' && createBudgetedLoop) { - const loop = createBudgetedLoop(paint, { fps: 15, idleWhen: idle }) - + const loop = createBudgetedLoop(paint, { + fps: 15, + idleWhen: idle + }) window.__hbFaceClock = { stop: () => { loop.dispose() @@ -3499,7 +3540,6 @@ function startFaceClock() { loop.wake() } } - return } @@ -3508,12 +3548,10 @@ function startFaceClock() { let rafId = 0 let dormant = false let stopped = false - const tick = now => { if (stopped) { return } - rafId = 0 // 15fps is smooth at avatar scale and bounds SVG/DOM churn. The clock // still uses rAF so Chromium can pause it when the window is occluded. @@ -3525,36 +3563,31 @@ function startFaceClock() { // Park instead of burning frames + 1Hz whole-document shadow walks. if (idle()) { dormant = true - return } - rafId = window.requestAnimationFrame(tick) } - const wake = () => { if (stopped || !dormant) { return } - dormant = false // Faces may have mounted/unmounted while parked — rescan on first tick. lastScan = -Infinity rafId = window.requestAnimationFrame(tick) } - const stop = () => { stopped = true - if (rafId) { window.cancelAnimationFrame(rafId) rafId = 0 } - teardownCaches() } - - window.__hbFaceClock = { stop, wake } + window.__hbFaceClock = { + stop, + wake + } rafId = window.requestAnimationFrame(tick) } @@ -3572,14 +3605,21 @@ function stopFaceClock() { */ function BotFace({ shape, color, image, size = 36, name = 'agent', mood = 'idle' }) { startFaceClock() - if (image) { - return jsx('img', { - src: image, - alt: '', - 'aria-hidden': true, - style: { width: size, height: size, borderRadius: '22%', objectFit: 'cover', display: 'block' } - }) + return ( + + ) } // Blobatar shapes: the library draws the whole face (body + eyes + its own @@ -3589,13 +3629,21 @@ function BotFace({ shape, color, image, size = 36, name = 'agent', mood = 'idle' // 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 } - }) + return ( + + ) } // Older SDK without blobatar: legacy deterministic shape from the name. @@ -3606,22 +3654,19 @@ function BotFace({ shape, color, image, size = 36, name = 'agent', mood = 'idle' // outlines, which would turn a stored sigil pick into a blank circle. // Keep the legacy static render for them so old picks still draw. if (shape.startsWith('sigil-')) { - const eyes = jsxs('g', { - children: [ - jsx('circle', { cx: 16, cy: 14, r: 2.4, fill: color }), - jsx('circle', { cx: 24, cy: 14, r: 2.4, fill: color }) - ] - }) - return jsxs('svg', { - 'data-bot-face': name, - viewBox: '0 0 40 40', - width: size, - height: size, - 'aria-hidden': true, - children: [shapeNode(shape, color, name), eyes] - }) + const eyes = ( + + + + + ) + return ( + + {shapeNode(shape, color, name)} + {eyes} + + ) } - const working = mood === 'work' const eyeFill = isDarkColor(color) ? 'rgba(232,220,195,0.95)' : 'rgba(0,0,0,0.85)' // Catchlight contrast follows the pupil, not the body: dark pupils get the @@ -3635,54 +3680,54 @@ function BotFace({ shape, color, image, size = 36, name = 'agent', mood = 'idle' // (and their catchlights) start at the cloud position instead of jumping // there on the first clock paint. const eyeY0 = shape === 'cloud' ? 22 : 17.2 - - return jsxs('svg', { - 'data-bot-face': name, - 'data-hb-math': '1', - 'data-hb-mood': working ? 'work' : 'idle', - 'data-hb-shape': shape || 'circle', - viewBox: '0 0 40 44', - width: size, - height: size, - 'aria-hidden': true, - style: { overflow: 'visible', display: 'block' }, - children: [ - jsx('path', { - 'data-hb-body': '1', - d: shape === 'cloud' - ? 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z' - : ringToPath(ring), - fill: color - }), - jsxs('g', { - 'data-hb-open': '1', - children: [ - jsx('ellipse', { 'data-hb-el': '1', cx: 15.4, cy: eyeY0, rx: 2.2, ry: working ? 2.6 : 2.3, fill: eyeFill }), - jsx('ellipse', { 'data-hb-er': '1', cx: 24.6, cy: eyeY0, rx: 2.2, ry: working ? 2.6 : 2.3, fill: eyeFill }), - jsx('circle', { 'data-hb-hl-l': '1', cx: 14.8, cy: eyeY0 - 0.7, r: 0.65, fill: hlFill }), - jsx('circle', { 'data-hb-hl-r': '1', cx: 24, cy: eyeY0 - 0.7, r: 0.65, fill: hlFill }) - ] - }), - jsx('path', { - 'data-hb-shut': '1', - d: `M12.8 ${eyeY0} L18 ${eyeY0} M22 ${eyeY0} L27.2 ${eyeY0}`, - stroke: eyeFill, - strokeWidth: 2, - strokeLinecap: 'round', - fill: 'none', - opacity: 0 - }), - working - ? jsxs('g', { - children: [ - jsx('circle', { 'data-hb-dot': '1', cx: 16.4, cy: 41.2, r: 1.15, fill: color, opacity: rest.d0 }), - jsx('circle', { 'data-hb-dot': '1', cx: 20, cy: 41.2, r: 1.15, fill: color, opacity: rest.d1 }), - jsx('circle', { 'data-hb-dot': '1', cx: 23.6, cy: 41.2, r: 1.15, fill: color, opacity: rest.d2 }) - ] - }) - : null - ] - }) + return ( + + + + + + + + + + {working ? ( + + + + + + ) : null} + + ) } // -- inline MCP setup (per-profile), driven by the mcp.servers.* gateway RPCs -- @@ -3695,13 +3740,22 @@ async function mcpRpc(method, params) { // doesn't know the method (older backend) vs a real error. try { const res = await host.request(method, params) - return { ok: true, result: res } + return { + ok: true, + result: res + } } catch (err) { const msg = String((err && err.message) || err || '') if (/unknown method/i.test(msg)) { - return { ok: false, unsupported: true } + return { + ok: false, + unsupported: true + } + } + return { + ok: false, + error: msg } - return { ok: false, error: msg } } } @@ -3715,7 +3769,6 @@ async function mcpSetupSupported() { _mcpRpcSupported = !(r.ok === false && r.unsupported) return _mcpRpcSupported } - function McpSetupButton({ profile, entry, onDone, ensureProfile }) { // entry: { name, requires:[env keys], auth?, fromCatalog, installed } // profile may be null at first (New Bot: the profile isn't created yet). @@ -3727,7 +3780,6 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { const [message, setMessage] = useState('') const pollRef = useRef(null) const profileRef = useRef(profile || null) - useEffect(() => { if (profile) { profileRef.current = profile @@ -3748,7 +3800,6 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { } return null } - useEffect(() => { let alive = true mcpSetupSupported().then(ok => { @@ -3762,10 +3813,8 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { } } }, []) - const isOAuth = (entry.auth || '').toLowerCase() === 'oauth' const requires = entry.requires || [] - const beginKeys = async () => { // Ensure the server exists in the target profile first (add from catalog). setPhase('busy') @@ -3776,7 +3825,11 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { return } if (entry.fromCatalog && !entry.installed) { - const add = await mcpRpc('mcp.servers.add', { profile, name: entry.name, preset: entry.name }) + const add = await mcpRpc('mcp.servers.add', { + profile, + name: entry.name, + preset: entry.name + }) if (!add.ok) { setPhase('error') setMessage(add.error || 'Could not add server') @@ -3785,7 +3838,6 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { } setPhase(isOAuth ? 'oauth' : 'keys') } - const submitKeys = async () => { setPhase('busy') const profile = profileRef.current @@ -3799,25 +3851,37 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { if (!val) { continue } - const r = await mcpRpc('mcp.servers.set_api_key', { profile, name: entry.name, env_var: k, value: val }) + const r = await mcpRpc('mcp.servers.set_api_key', { + profile, + name: entry.name, + env_var: k, + value: val + }) if (!r.ok) { setPhase('error') - setMessage(r.error || ('Failed to set ' + k)) + setMessage(r.error || 'Failed to set ' + k) return } } // Verify via test. - const t = await mcpRpc('mcp.servers.test', { profile, name: entry.name }) + const t = await mcpRpc('mcp.servers.test', { + profile, + name: entry.name + }) if (t.ok && t.result && (t.result.ok || (t.result.result && t.result.result.ok))) { setPhase('done') - host.notify({ kind: 'success', message: entry.name + ' configured' }) + host.notify({ + kind: 'success', + message: entry.name + ' configured' + }) onDone && onDone() } else { setPhase('error') - setMessage((t.result && (t.result.error || (t.result.result && t.result.result.error))) || 'Server test failed after setup') + setMessage( + (t.result && (t.result.error || (t.result.result && t.result.result.error))) || 'Server test failed after setup' + ) } } - const beginOAuth = async () => { // A second click (retry, impatient double-click) must not orphan the // previous poll interval — an overwritten pollRef leaks a 2s poller that @@ -3834,20 +3898,27 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { return } if (entry.fromCatalog && !entry.installed) { - const add = await mcpRpc('mcp.servers.add', { profile, name: entry.name, preset: entry.name }) + const add = await mcpRpc('mcp.servers.add', { + profile, + name: entry.name, + preset: entry.name + }) if (!add.ok) { setPhase('error') setMessage(add.error || 'Could not add server') return } } - const start = await mcpRpc('mcp.servers.oauth.start', { profile, name: entry.name }) + const start = await mcpRpc('mcp.servers.oauth.start', { + profile, + name: entry.name + }) const payload = start.result && (start.result.result || start.result) const authUrl = payload && (payload.auth_url || payload.verification_url) const sessionId = payload && payload.session_id if (!start.ok || !authUrl || !sessionId) { setPhase('error') - setMessage((start.error) || 'Could not start OAuth') + setMessage(start.error || 'Could not start OAuth') return } // Open the auth URL in the native browser, same as provider OAuth. @@ -3865,14 +3936,21 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { setPhase('oauth') setMessage('Complete sign-in in your browser...') pollRef.current = setInterval(async () => { - const poll = await mcpRpc('mcp.servers.oauth.poll', { profile, name: entry.name, session_id: sessionId }) + const poll = await mcpRpc('mcp.servers.oauth.poll', { + profile, + name: entry.name, + session_id: sessionId + }) const pd = poll.result && (poll.result.result || poll.result) const status = pd && pd.status if (status === 'approved') { clearInterval(pollRef.current) pollRef.current = null setPhase('done') - host.notify({ kind: 'success', message: entry.name + ' authenticated' }) + host.notify({ + kind: 'success', + message: entry.name + ' authenticated' + }) onDone && onDone() } else if (status === 'error') { clearInterval(pollRef.current) @@ -3882,60 +3960,72 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { } }, 2000) } - if (supported === false) { - return jsx('span', { - className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)', - children: 'needs setup (' + requires.join(', ') + ') \u2014 restart the gateway to enable in-app setup' - }) + return ( + + {'needs setup (' + requires.join(', ') + ') \u2014 restart the gateway to enable in-app setup'} + + ) } if (phase === 'done') { - return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-success,#22c55e)', children: 'set up \u2713' }) + return set up ✓ } if (phase === 'keys') { - return jsxs('div', { - className: 'mt-1 grid gap-1', - children: [ - ...requires.map(k => - jsx(Input, { - key: k, - type: 'password', - className: 'h-6 text-[0.7rem]', - placeholder: k, - value: keyValues[k] || '', - onChange: e => setKeyValues(prev => ({ ...prev, [k]: e.target.value })) - }, k) - ), - jsxs('div', { - className: 'flex gap-1', - children: [ - jsx(Button, { size: 'xs', variant: 'secondary', onClick: () => void submitKeys(), children: 'Save & test' }), - jsx(Button, { size: 'xs', variant: 'ghost', onClick: () => setPhase('idle'), children: 'Cancel' }) - ] - }) - ] - }) + return ( +
+ {requires.map(k => ( + + setKeyValues(prev => ({ + ...prev, + [k]: e.target.value + })) + } + /> + ))} +
+ + +
+
+ ) } if (phase === 'oauth') { - return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)', children: message || 'Authorizing\u2026' }) + return {message || 'Authorizing\u2026'} } if (phase === 'busy') { - return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)', children: 'Working\u2026' }) + return Working… } if (phase === 'error') { - return jsxs('span', { - className: 'ml-1.5 text-[0.65rem] text-(--ui-danger,#ef4444)', - children: [(message || 'Setup failed') + ' ', jsx('button', { className: 'underline', onClick: () => setPhase('idle'), children: 'retry' })] - }) + return ( + + {(message || 'Setup failed') + ' '} + + + ) } // idle - return jsx('button', { - className: 'ml-1.5 text-[0.65rem] text-(--ui-accent,#4f9cf9) underline', - onClick: () => void (isOAuth ? beginOAuth() : beginKeys()), - children: isOAuth ? 'Sign in\u2026' : 'Set up\u2026' - }) + return ( + + ) } - function botAppearance(name, meta) { // The primary profile is literally named "default"; the SDK's profileColor // can hand it a near-black that renders as an ugly black square, and any @@ -3946,7 +4036,11 @@ function botAppearance(name, meta) { const isPrimary = (name || '').trim().toLowerCase() === 'default' const userCustomized = Boolean(meta?.custom) if (isPrimary && !userCustomized) { - return { shape: 'squircle', color: '#8b5cf6', image: meta?.image || null } + return { + shape: 'squircle', + color: '#8b5cf6', + image: meta?.image || null + } } return { shape: meta?.shape || defaultShapeFor(name), @@ -3978,7 +4072,6 @@ function normalizeAvatarImage(dataUrl, edge = 256) { img.src = dataUrl }) } - function pickImageFromDevice() { return new Promise(resolve => { const input = document.createElement('input') @@ -3986,16 +4079,16 @@ function pickImageFromDevice() { input.accept = 'image/png,image/jpeg,image/webp,image/gif' input.onchange = () => { const file = input.files?.[0] - if (!file) { return resolve(null) } - if (file.size > 15_000_000) { - host.notify({ kind: 'error', message: 'Image too large (max 15MB).' }) + host.notify({ + kind: 'error', + message: 'Image too large (max 15MB).' + }) return resolve(null) } - const reader = new FileReader() reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null) reader.onerror = () => resolve(null) @@ -4012,11 +4105,9 @@ function groupAttachmentKind(file) { if (/^image\//.test(file.type || '')) { return 'image' } - if (file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '')) { return 'pdf' } - return 'file' } @@ -4026,28 +4117,26 @@ function groupAttachmentKind(file) { * picker button, the composer paste handler, and room drag & drop. */ async function filesToGroupAttachments(files) { const picked = [] - for (const file of [...(files || [])]) { if (!file) { continue } - if (file.size > 15_000_000) { - host.notify({ kind: 'error', message: `${file.name || 'attachment'}: too large (max 15MB).` }) + host.notify({ + kind: 'error', + message: `${file.name || 'attachment'}: too large (max 15MB).` + }) continue } - const data = await new Promise(done => { const reader = new FileReader() reader.onload = () => done(typeof reader.result === 'string' ? reader.result : null) reader.onerror = () => done(null) reader.readAsDataURL(file) }) - if (!data) { continue } - const kind = groupAttachmentKind(file) picked.push({ name: file.name || (kind === 'image' ? 'pasted image' : 'attachment'), @@ -4055,7 +4144,6 @@ async function filesToGroupAttachments(files) { kind }) } - return picked } @@ -4081,11 +4169,9 @@ function normalizeGroupAttachment(dataUrl, maxEdge = 1568) { img.onload = () => { try { const long = Math.max(img.width, img.height) - if (!long || long <= maxEdge) { return resolve(dataUrl) } - const scale = maxEdge / long const canvas = document.createElement('canvas') canvas.width = Math.max(1, Math.round(img.width * scale)) @@ -4107,23 +4193,21 @@ function normalizeGroupAttachment(dataUrl, maxEdge = 1568) { * Only `true` is sticky. */ const $imagenAvailable = atom(null) let imagenProbeInflight = null - function probeImagen() { if (imagenProbeInflight) { return imagenProbeInflight } - imagenProbeInflight = host - .request('image.generate', { probe: true }) + .request('image.generate', { + probe: true + }) .then(res => $imagenAvailable.set(Boolean(res?.available))) .catch(() => $imagenAvailable.set(false)) .finally(() => { imagenProbeInflight = null }) - return imagenProbeInflight } - async function generateAvatarImage(bot, title, description) { const who = [title || bot, description].filter(Boolean).join(' — ') const res = await host.request('image.generate', { @@ -4132,7 +4216,6 @@ async function generateAvatarImage(bot, title, description) { 'Friendly simple mascot face, bold flat vector style, solid color background, centered, no text.', aspect_ratio: 'square' }) - if (!res?.success) { throw new Error(res?.error || 'generation failed') } @@ -4152,7 +4235,6 @@ function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generate const [tab, setTab] = useState('bot') const [describe, setDescribe] = useState('') const [genBusy, setGenBusy] = useState(false) - if (imagen === null) { void probeImagen() } @@ -4161,28 +4243,22 @@ function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generate // tab — the gateway may have restarted with image.generate since. const goTab = id => { setTab(id) - if (id === 'generate' && $imagenAvailable.get() === false) { $imagenAvailable.set(null) void probeImagen() } } - const upload = async () => { const raw = await pickImageFromDevice() - if (raw) { onImage(await normalizeAvatarImage(raw)) } } - const generate = async () => { if (genBusy) { return } - setGenBusy(true) - try { const custom = describe.trim() const img = custom @@ -4191,15 +4267,12 @@ function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generate prompt: `${custom}. Avatar for an AI agent: centered, bold flat vector style, solid color background, no text.`, aspect_ratio: 'square' }) - if (!res?.success) { throw new Error(res?.error || 'generation failed') } - return res.image_data || res.image })() : await generateAvatarImage(generateSeed?.name || 'agent', generateSeed?.title, generateSeed?.description) - if (img) { onImage(await normalizeAvatarImage(img)) } @@ -4209,237 +4282,226 @@ function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generate setGenBusy(false) } } - - const tabButton = (id, label) => - jsx( - 'button', - { - type: 'button', - className: cn( - 'rounded-full px-3 py-1 text-xs font-medium transition-colors', - tab === id - ? 'bg-(--chrome-action-hover) text-foreground' - : 'text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)' - ), - onClick: () => goTab(id), - children: label - }, - id - ) - - return jsxs('div', { - className: 'grid justify-items-center gap-3', - children: [ - // Tab pills: Bot | Generate | Upload | Pet - jsxs('div', { - className: 'flex items-center gap-1', - children: [tabButton('bot', 'Bot'), tabButton('generate', 'Generate'), tabButton('upload', 'Upload'), tabButton('pet', 'Pet')] - }), - - image && tab !== 'generate' - ? jsx(Button, { - type: 'button', - variant: 'ghost', - size: 'sm', - onClick: () => onImage(null), - children: 'Remove image — use shape' - }) - : null, - - tab === 'bot' - ? 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', { - style: { - display: 'grid', - gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', - gap: '6px', - justifyItems: 'center' - }, - 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( + const tabButton = (id, label) => ( + + ) + return ( +
+ {/* Tab pills: Bot | Generate | Upload | Pet */} +
+ {tabButton('bot', 'Bot')} + {tabButton('generate', 'Generate')} + {tabButton('upload', 'Upload')} + {tabButton('pet', 'Pet')} +
+ {image && tab !== 'generate' ? ( + + ) : null} + {tab === 'bot' ? ( + isBlobShape(shape) && blobatarSvg ? ( + (() => { + const { seedPart, kind } = parseBlobShape(shape, pickerName) + const locked = Boolean(seedPart) + return ( +
+ {/* Silhouette pins: Auto (name decides) + the six blob kinds. */} +
+ {['', ...BLOB_KINDS].map(k => ( + + ))} +
+
+ + +
+
+ {locked ? 'Face locked — renaming won\u2019t change it.' : 'Face follows the name.'} +
+ +
+ ) + })() + ) : ( +
+
+ {(blobatarSvg ? ['blobatar', ...AVATAR_PICKER_SHAPES] : AVATAR_PICKER_SHAPES).map(s => ( + + ))} +
+
+ {AVATAR_COLORS.map(c => ( +
+
+ ) + ) : null} + {tab === 'generate' ? ( + imagen ? ( +
+