fix(desktop): renamed Bot Mode agents stay @-taggable by their new name
Renaming a bot (Bot Mode title or 'hermes profile rename' display_name) changed the roster row but not what the user could @-tag it with — mentions still only resolved the original profile handle, and the composer autocomplete never offered the new name. - mentionNameForms()/botFriendlyNames()/botMentionTag(): one resolver for the taggable forms a friendly name yields (slugged + collapsed), with reserved tokens (hermes/default/everyone/all/user) excluded so a rename can never hijack them. - resolveRosterMentions() and parseGroupChatMentions() accept the friendly forms alongside the profile name/handle (both keep working). - Composer @ autocomplete (global provider + group-room popover) inserts the renamed tag and prefix-matches on tag, handle, and display name. - Mention middleware's cold-cache fallback now runs the same resolver instead of a bare-names-only parse, so renamed tags resolve there too. - durableGroupChatMembers persists title/display_name so renamed-tag mentions survive connection switches in cross-machine rooms. - Docs: bot-mode.md documents renamed tags.
This commit is contained in:
@@ -2969,6 +2969,53 @@ function botHandle(name, bot) {
|
||||
return (name || '').trim().toLowerCase() === 'default' ? 'hermes' : name
|
||||
}
|
||||
|
||||
/** Taggable @-forms derived from a bot's friendly names — the core profile
|
||||
* display name (`hermes profile rename`) and the Bot Mode title. Free text
|
||||
* reduces to the mention charset two ways: slugified ("Research Buddy" →
|
||||
* research-buddy, the form autocomplete inserts) and collapsed
|
||||
* (researchbuddy). Reserved tokens are dropped so a bot renamed "Hermes"
|
||||
* can never hijack the primary profile's @hermes alias. */
|
||||
function mentionNameForms(value) {
|
||||
const name = String(value || '').trim().toLowerCase()
|
||||
|
||||
if (!name) {
|
||||
return []
|
||||
}
|
||||
|
||||
const slug = name.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
const collapsed = name.replace(/[^a-z0-9_-]+/g, '')
|
||||
|
||||
return [...new Set([slug, collapsed])].filter(
|
||||
form => /^[a-z0-9][a-z0-9_-]*$/.test(form) && !['all', 'everyone', 'user', 'default', 'hermes'].includes(form)
|
||||
)
|
||||
}
|
||||
|
||||
/** Every friendly (renameable) name a roster row carries: the Bot Mode title
|
||||
* (server-synced via ui_meta, locally stored, or persisted on a durable
|
||||
* group descriptor) and the core profile display_name — in displayName's
|
||||
* precedence order. Remote rows never borrow local meta (two `default`s
|
||||
* must not share a title). */
|
||||
function botFriendlyNames(bot) {
|
||||
const localTitle = !bot?.remoteSource && typeof $botMeta !== 'undefined' ? $botMeta.get()?.[bot?.name]?.title : null
|
||||
|
||||
return [bot?.ui_meta?.['hermes-bots']?.title, localTitle, bot?.title, bot?.display_name]
|
||||
}
|
||||
|
||||
/** The tag autocomplete inserts for a bot: the renamed (friendly) slug when
|
||||
* the user gave the bot a real name, otherwise the profile @handle. The
|
||||
* resolvers accept both, so older muscle memory keeps working. */
|
||||
function botMentionTag(bot) {
|
||||
for (const friendly of botFriendlyNames(bot)) {
|
||||
const forms = mentionNameForms(friendly)
|
||||
|
||||
if (forms.length) {
|
||||
return forms[0]
|
||||
}
|
||||
}
|
||||
|
||||
return botHandle(bot?.name, bot)
|
||||
}
|
||||
|
||||
function isActiveRosterBot(bot, active) {
|
||||
const activeName = String(active?.name || 'default').trim() || 'default'
|
||||
const activeId = String(active?.connectionId || '').trim()
|
||||
@@ -3007,6 +3054,15 @@ function resolveRosterMentions(text, roster, active = {}) {
|
||||
forms.add(String(bot.handle).toLowerCase())
|
||||
}
|
||||
|
||||
// Renamed bots are taggable by their friendly names too — the core
|
||||
// profile display_name and the Bot Mode title (issue: renaming a bot
|
||||
// didn't change what you @-tag it with).
|
||||
for (const friendly of botFriendlyNames(bot)) {
|
||||
for (const form of mentionNameForms(friendly)) {
|
||||
forms.add(form)
|
||||
}
|
||||
}
|
||||
|
||||
for (const form of forms) {
|
||||
if (!form) {
|
||||
continue
|
||||
@@ -3697,15 +3753,24 @@ function groupChatMemberBots(group, roster, metaByName) {
|
||||
* source's row may become remote after a connection switch, so retaining it
|
||||
* here is what keeps the same room intact across machines. */
|
||||
function durableGroupChatMembers(bots) {
|
||||
return (bots || []).map(bot => ({
|
||||
name: bot.name,
|
||||
handle: bot.handle || bot.name,
|
||||
connectionId: bot.connectionId,
|
||||
connectionKind: bot.connectionKind,
|
||||
connectionLabel: bot.connectionLabel,
|
||||
remoteSource: true,
|
||||
sourceScoped: true
|
||||
}))
|
||||
return (bots || []).map(bot => {
|
||||
// Keep the friendly identity on the stored descriptor: after a
|
||||
// connection switch the live roster row may be gone, and renamed-tag
|
||||
// mentions must still resolve against the persisted member.
|
||||
const title = String(botRosterMeta(bot, $botMeta.get())?.title || bot.ui_meta?.['hermes-bots']?.title || bot.title || '').trim()
|
||||
|
||||
return {
|
||||
name: bot.name,
|
||||
handle: bot.handle || bot.name,
|
||||
...(title ? { title } : {}),
|
||||
...(bot.display_name ? { display_name: bot.display_name } : {}),
|
||||
connectionId: bot.connectionId,
|
||||
connectionKind: bot.connectionKind,
|
||||
connectionLabel: bot.connectionLabel,
|
||||
remoteSource: true,
|
||||
sourceScoped: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Existing group names, alphabetical — feeds the Manage-groups dialog. */
|
||||
@@ -3774,6 +3839,15 @@ function parseGroupChatMentions(text, members) {
|
||||
: [])
|
||||
])
|
||||
|
||||
// Renamed members answer to their friendly names too (profile
|
||||
// display_name and Bot Mode title), in slugged and collapsed forms —
|
||||
// the same tags the roster autocomplete inserts.
|
||||
for (const friendly of botFriendlyNames(member)) {
|
||||
for (const form of mentionNameForms(friendly)) {
|
||||
forms.add(form)
|
||||
}
|
||||
}
|
||||
|
||||
for (const form of forms) {
|
||||
if (form) {
|
||||
handles.set(form, groupMemberKey(member))
|
||||
@@ -8658,14 +8732,26 @@ function GroupMentionInput({ members, onChange, value, ...inputProps }) {
|
||||
|
||||
for (const member of members) {
|
||||
const handle = String(member.handle || botHandle(member.name, member) || '').trim()
|
||||
const display = displayName(member, botRosterMeta(member, allMeta))
|
||||
// Renamed members complete on their friendly tag; parser resolves both.
|
||||
const tag = String(botMentionTag(member) || handle).trim()
|
||||
|
||||
if (!handle || (token.query && !handle.toLowerCase().startsWith(token.query))) {
|
||||
if (!tag) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
token.query &&
|
||||
!tag.toLowerCase().startsWith(token.query) &&
|
||||
!(handle && handle.toLowerCase().startsWith(token.query)) &&
|
||||
!display.toLowerCase().startsWith(token.query)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
options.push({
|
||||
handle,
|
||||
meta: displayName(member, botRosterMeta(member, allMeta))
|
||||
handle: tag,
|
||||
meta: display
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10109,17 +10195,25 @@ export default {
|
||||
}
|
||||
|
||||
const handle = botHandle(profile.name, profile)
|
||||
const display = displayName(profile, $botMeta.get()[profile.name])
|
||||
// Renamed bots complete on their friendly name — the tag is the
|
||||
// renamed slug when one exists, the profile handle otherwise.
|
||||
const tag = botMentionTag(profile)
|
||||
|
||||
if (q && !handle.toLowerCase().startsWith(q)) {
|
||||
if (
|
||||
q &&
|
||||
!tag.toLowerCase().startsWith(q) &&
|
||||
!handle.toLowerCase().startsWith(q) &&
|
||||
!display.toLowerCase().startsWith(q)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const display = displayName(profile, $botMeta.get()[profile.name])
|
||||
const source = profile.connectionLabel ? ` · ${profile.connectionLabel}` : ''
|
||||
|
||||
items.push({
|
||||
insert: `@${handle}`,
|
||||
display: `@${handle}`,
|
||||
insert: `@${tag}`,
|
||||
display: `@${tag}`,
|
||||
meta: `Bot · ${display}${source}`
|
||||
})
|
||||
}
|
||||
@@ -10397,30 +10491,14 @@ export default {
|
||||
let mentionedBots = roster ? resolveRosterMentions(text, roster, live) : []
|
||||
|
||||
if (!roster) {
|
||||
let names = []
|
||||
try {
|
||||
const res = await host.request('profiles.list', { include_sessions: false })
|
||||
names = (res?.profiles ?? []).map(p => p.name)
|
||||
// Same resolver as the cached path — renamed bots (display_name
|
||||
// / ui_meta title) stay taggable when the roster cache is cold.
|
||||
mentionedBots = resolveRosterMentions(text, res?.profiles ?? [], live).map(bot => ({ ...bot, remoteSource: false }))
|
||||
} catch {
|
||||
return draft
|
||||
}
|
||||
|
||||
const prose = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ')
|
||||
const mentioned = []
|
||||
|
||||
for (const match of prose.matchAll(/(^|\s)@([a-z0-9][a-z0-9_-]*)/gi)) {
|
||||
let name = match[2].toLowerCase()
|
||||
|
||||
if (name === 'hermes' && !names.includes('hermes') && names.includes('default')) {
|
||||
name = 'default'
|
||||
}
|
||||
|
||||
if (names.includes(name) && name !== live.name && !mentioned.includes(name)) {
|
||||
mentioned.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
mentionedBots = mentioned.map(name => ({ name }))
|
||||
}
|
||||
|
||||
if (!mentionedBots.length) {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
// Renamed bots stay taggable (Discord report, Aug 2026): renaming a bot —
|
||||
// Bot Mode title or `hermes profile rename` display_name — must update what
|
||||
// the user can @-tag it with, in the mention middleware, group rooms, and
|
||||
// the composer autocomplete. Old profile handles keep resolving.
|
||||
|
||||
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
function runtime({ meta } = {}) {
|
||||
const context = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
Date,
|
||||
URL,
|
||||
atom: initial => {
|
||||
let value = initial
|
||||
return { get: () => value, set: next => (value = next), listen: () => () => undefined }
|
||||
},
|
||||
host: {
|
||||
request: async () => ({}),
|
||||
requestProfile: async () => ({}),
|
||||
state: {
|
||||
profile: { get: () => 'default', listen: () => undefined },
|
||||
connectionId: { get: () => 'local', listen: () => undefined },
|
||||
gateway: { listen: () => undefined }
|
||||
}
|
||||
},
|
||||
document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }
|
||||
}
|
||||
const code = source
|
||||
.replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
|
||||
.replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
|
||||
.replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '')
|
||||
.replace(/^import .* from 'react'\r?\n/m, '')
|
||||
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
|
||||
.replace('export default {', 'globalThis.plugin = {')
|
||||
.concat(
|
||||
'\nglobalThis.__x = { mentionNameForms, botFriendlyNames, botMentionTag, resolveRosterMentions, parseGroupChatMentions, groupMemberKey, durableGroupChatMembers, $botMeta };\n'
|
||||
)
|
||||
vm.runInNewContext(code, context, { filename: 'plugin.js' })
|
||||
if (meta) {
|
||||
context.__x.$botMeta.set(meta)
|
||||
}
|
||||
return context.__x
|
||||
}
|
||||
|
||||
test('mentionNameForms: slugged + collapsed, reserved tokens dropped', () => {
|
||||
const { mentionNameForms } = runtime()
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(mentionNameForms('Research Buddy'))), ['research-buddy', 'researchbuddy'])
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(mentionNameForms('Ops'))), ['ops'])
|
||||
// A bot renamed "Hermes" or "everyone" cannot hijack reserved tags.
|
||||
assert.equal(mentionNameForms('Hermes').length, 0)
|
||||
assert.equal(mentionNameForms('@everyone').length, 0)
|
||||
assert.equal(mentionNameForms('').length, 0)
|
||||
})
|
||||
|
||||
test('botMentionTag: renamed slug wins, profile handle is the fallback', () => {
|
||||
const { botMentionTag } = runtime({ meta: { writer: { title: 'Research Buddy' } } })
|
||||
|
||||
assert.equal(botMentionTag({ name: 'writer' }), 'research-buddy')
|
||||
assert.equal(botMentionTag({ name: 'ops' }), 'ops')
|
||||
assert.equal(botMentionTag({ name: 'default' }), 'hermes')
|
||||
// display_name (hermes profile rename) drives the tag too.
|
||||
assert.equal(botMentionTag({ name: 'scout', display_name: 'Deal Finder' }), 'deal-finder')
|
||||
})
|
||||
|
||||
test('resolveRosterMentions: renamed bots resolve by friendly tag AND old handle', () => {
|
||||
const { resolveRosterMentions } = runtime({ meta: { writer: { title: 'Research Buddy' } } })
|
||||
const roster = [{ name: 'default' }, { name: 'writer' }, { name: 'scout', display_name: 'Deal Finder' }]
|
||||
const live = { name: 'default', connectionId: 'local' }
|
||||
|
||||
const byTitle = resolveRosterMentions('hey @research-buddy check this', roster, live)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(byTitle.map(b => b.name))), ['writer'])
|
||||
|
||||
const byDisplayName = resolveRosterMentions('ping @dealfinder please', roster, live)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(byDisplayName.map(b => b.name))), ['scout'])
|
||||
|
||||
// The profile name keeps working after a rename.
|
||||
const byName = resolveRosterMentions('hey @writer', roster, live)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(byName.map(b => b.name))), ['writer'])
|
||||
})
|
||||
|
||||
test('parseGroupChatMentions: display_name and ui_meta title forms address a member', () => {
|
||||
const { parseGroupChatMentions, groupMemberKey } = runtime()
|
||||
const members = [
|
||||
{ name: 'research', title: '' },
|
||||
{ name: 'builder', title: '', display_name: 'Site Smith' },
|
||||
{ name: 'ops', title: '', ui_meta: { 'hermes-bots': { title: 'Night Watch' } } }
|
||||
]
|
||||
|
||||
const bySlug = parseGroupChatMentions('@site-smith and @night-watch please', members)
|
||||
assert.equal(bySlug.mentioned.has(groupMemberKey(members[1])), true)
|
||||
assert.equal(bySlug.mentioned.has(groupMemberKey(members[2])), true)
|
||||
assert.equal(bySlug.mentioned.has(groupMemberKey(members[0])), false)
|
||||
|
||||
const collapsed = parseGroupChatMentions('@sitesmith take a look', members)
|
||||
assert.equal(collapsed.mentioned.has(groupMemberKey(members[1])), true)
|
||||
})
|
||||
|
||||
test('durableGroupChatMembers persists the friendly identity for renamed-tag mentions', () => {
|
||||
const { durableGroupChatMembers, $botMeta } = runtime()
|
||||
$botMeta.set({ writer: { title: 'Research Buddy' } })
|
||||
|
||||
const [stored] = durableGroupChatMembers([{ name: 'writer', connectionId: 'local' }])
|
||||
assert.equal(stored.title, 'Research Buddy')
|
||||
|
||||
const [remote] = durableGroupChatMembers([
|
||||
{ name: 'scout', display_name: 'Deal Finder', connectionId: 'mac-mini', remoteSource: true }
|
||||
])
|
||||
assert.equal(remote.display_name, 'Deal Finder')
|
||||
})
|
||||
|
||||
test('composer autocomplete offers the renamed tag and matches on the display name', () => {
|
||||
const registrations = []
|
||||
const atom = value => {
|
||||
let current = value
|
||||
return { get: () => current, set: next => (current = next), listen: () => () => undefined }
|
||||
}
|
||||
const jsx = (type, props = {}) => ({ type, props })
|
||||
const roster = { profiles: [{ name: 'default' }, { name: 'writer' }, { name: 'ops' }] }
|
||||
const context = {
|
||||
atom,
|
||||
jsx,
|
||||
jsxs: jsx,
|
||||
useQuery: () => ({}),
|
||||
useValue: v => (v?.get ? v.get() : v),
|
||||
useState: v => [v, () => undefined],
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
performance,
|
||||
window: {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
performance,
|
||||
requestAnimationFrame: () => 0,
|
||||
cancelAnimationFrame: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined
|
||||
},
|
||||
document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } },
|
||||
queryClient: { getQueryData: () => roster, invalidateQueries: () => undefined, setQueryData: () => undefined },
|
||||
host: {
|
||||
state: { profile: { get: () => 'default', listen: () => () => undefined }, gateway: { get: () => null, listen: () => () => undefined } },
|
||||
request: async () => ({}),
|
||||
onEvent: () => () => undefined
|
||||
},
|
||||
COMPOSER_AREAS: { middleware: 'composer.middleware', atCompletions: 'composer.atCompletions' },
|
||||
sdk: new Proxy({}, { get: () => undefined })
|
||||
}
|
||||
const code = source
|
||||
.replace(/^import \* as sdk from '@hermes\/plugin-sdk'\r?\n/m, '')
|
||||
.replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
|
||||
.replace(/^import .* from 'react'\r?\n/m, '')
|
||||
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
|
||||
.replace('export default {', 'globalThis.plugin = {')
|
||||
.concat('\nglobalThis.__botMeta = $botMeta;')
|
||||
vm.runInNewContext(code, context)
|
||||
context.__botMeta.set({ writer: { title: 'Research Buddy' } })
|
||||
|
||||
try {
|
||||
context.plugin.register({
|
||||
register: c => registrations.push(c),
|
||||
storage: { get: async () => undefined, set: async () => undefined }
|
||||
})
|
||||
} catch {
|
||||
/* registration walks UI surfaces the stub doesn't fully model */
|
||||
}
|
||||
|
||||
const provide = registrations.find(c => c.area === 'composer.atCompletions').data.provide
|
||||
|
||||
// The renamed bot completes under its friendly prefix and inserts the tag.
|
||||
const renamed = provide('rese')
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(renamed.map(i => i.insert))), ['@research-buddy'])
|
||||
|
||||
// Old profile-handle muscle memory still finds it (insert is the new tag).
|
||||
const byHandle = provide('wri')
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(byHandle.map(i => i.insert))), ['@research-buddy'])
|
||||
|
||||
// Un-renamed bots keep their plain handle.
|
||||
const plain = provide('op')
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(plain.map(i => i.insert))), ['@ops'])
|
||||
})
|
||||
@@ -94,6 +94,7 @@ Groups are standalone rows in the same activity-ordered roster as Bot DMs. A Bot
|
||||
Bots message each other with attribution, and you can hand work off from any chat:
|
||||
|
||||
- **@mentions** — type `@researcher have a look at this` in any chat and the active Bot hands the message off, waits for the reply, and reports back. Mention names are validated against the live roster, so an email address or an unknown `@` passes through untouched.
|
||||
- **Renamed Bots keep their tags in sync** — give a Bot a friendly name (the pencil in its chat header, or `hermes profile rename`) and it becomes taggable by that name: a Bot titled *Research Buddy* answers to `@research-buddy` (and `@researchbuddy`), in regular chats and in group rooms alike. The composer's `@` autocomplete offers the renamed tag and also matches when you type the old profile name, which keeps resolving too.
|
||||
- **@mentions across machines** — mentioning a Bot that lives on another registered connection (use its `@name-device` handle when names collide) delivers over the Connections registry in the background: the active Bot stays on this device, the desktop routes the message to the recipient's machine, and the reply is relayed back attributed to that agent. Your window's gateway never switches.
|
||||
- **Direct messages** — a Bot reaches a teammate's Bot Chat through the standard CLI: it writes the message to a temp file (opening with the `Message from 🤖 <sender> (@<sender>):` prefix), then runs `hermes -p <bot> chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file <file>`. The file transport means nothing is shell-interpreted — quotes, `$(...)`, and backticks in the message arrive verbatim. The receiving Bot sees the message the next time it runs and knows how to reply, because the messaging protocol is part of its Bot Chat system prompt.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user