Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,72 @@ plug into `agent/context_engine.py`; image-gen providers into
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
companion repo, not in this tree.

### Bot Mode (`apps/desktop/src/plugins/hermes-bots/`)

The desktop "Bots" experience ships bundled in-tree. Each bot is a Hermes
agent **profile** with a persistent identity. Its design rests on one settled
invariant that has been regressed repeatedly, cost users real conversation
history each time, and is not open for re-litigation in a routine PR:

**One bot = ONE canonical forever-chat, identified by NAME.** The chat's one
and only identity is **(profile, session titled exactly "Bot Chat")** — the
state DB's UNIQUE(title) index makes that pair an exact registry of at most
one row. The full lifecycle when a bot row is clicked:

1. **Resolve the registry, every time.** Look up the profile's `Bot Chat`
session by exact title via `session.list {title, include_hidden: true}`
(indexed, window-free; hidden rows resolve because canonical chats are
always hidden; compression lineages resolve to the live tip). Row exists →
open it. That is the entire happy path.
2. **No row → create it,** titled `Bot Chat`, born hidden, kicked off with
the bot's intro. Creation adopts-before-minting: it re-runs the registry
lookup first, so a concurrent or pre-existing row is opened, never forked.
(`set_session_title` silently drops conflicting titles — returns 0 rows —
which is how the 2026-08 infinite fork loop started; adopt-before-mint is
what kills it.)

**There is NO session-id pin.** The previous design stored a pointer in
`ui_meta['hermes-bots'].chat` and verified it per click; five hardening
waves (#88690, #90732, #90751, the #91791 revert, #92042) each guarded a new
way that pointer dangled or got stolen — rows[0] steals, `last_session`
adoptions, transient clears, drifted-title welds (a pin re-anchored onto a
cron session passed every guard). Name-as-identity removes the failure class:
a name cannot dangle, and a corrupted historical pointer simply never gets
read. Legacy `chat` keys in ui_meta are ignored and dropped from merges.

Why recency must never win (the #91791 → #92042 lesson): canonical Bot
Chats are **unconditionally hidden** from the Sessions sidebar, so the bot
row is the ONLY door to the forever-chat. A "newest visible session wins"
preference doesn't re-order two equivalent entry points — it walls the
entire relationship off behind a row that previews one session and opens
another, and any stray draft that catches a prompt captures the row.
Side-chats started via "New chat with this agent" are not plumbing-titled,
stay visible in the Sessions sidebar, and are reachable there; they are
never the bot row's target.

Corollaries for reviewers:

- There is no per-bot session browser, by explicit design (removed in
#90732). Do not add one back.
- Reject any PR that reintroduces a stored session-id pointer as canonical
identity — including "as a fallback tier" or "for verification". The
registry lookup is the whole contract; pointers are how every prior
incident started.
- Reject any PR that consults recency, visibility, or "where the user left
off" for the bot row's target — reports that motivate such a change are
almost always about side-chats, and the fix belongs in the Sessions
sidebar (hide-sweep false positives), not in the bot row's target.
- The gateway reports the registry row per profile as `canonical_session`
on `profiles.list` (resolved server-side by title); roster preview,
activity signals, and the `/new`→`/compact` guard all read it, so preview
identity and click identity are the same row by construction.

Regression tests encoding this contract:
`tests/canonical-chat-registry.test.mjs` (includes a tripwire asserting the
open path never reads or writes a stored pointer),
`tests/canonical-chat-creation.test.mjs`, `tests/hide-bot-chats.test.mjs`,
and `tests/tui_gateway/test_profiles_list_canonical_session.py`.

---

## Skills
Expand Down
434 changes: 97 additions & 337 deletions apps/desktop/src/plugins/hermes-bots/plugin.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ test('roster without profiles never throws', () => {

// ── botActivitySession: canonical Bot Chat activity counts (hermes-agent "6d ago" bug) ──

test('botActivitySession picks the fresher preferred_session over a stale last_session', () => {
test('botActivitySession picks the fresher canonical_session over a stale last_session', () => {
const botActivitySession = loadBotActivitySession()
const bot = {
// Canonical Bot Chat (hidden from session lists): messaged seconds ago.
preferred_session: { id: 'bot-chat', last_active: NOW / 1000 - 5, preview: 'fresh DM' },
canonical_session: { id: 'bot-chat', last_active: NOW / 1000 - 5, preview: 'fresh DM' },
// Newest VISIBLE session: 6 days old — what last_session alone reports.
last_session: { id: 'old-scratch', last_active: NOW / 1000 - 6 * 86400, preview: 'ancient' }
}
Expand All @@ -98,7 +98,7 @@ test('botActivitySession picks the fresher preferred_session over a stale last_s
test('botActivitySession keeps last_session when it is the fresher one', () => {
const botActivitySession = loadBotActivitySession()
const bot = {
preferred_session: { id: 'bot-chat', last_active: NOW / 1000 - 3600 },
canonical_session: { id: 'bot-chat', last_active: NOW / 1000 - 3600 },
last_session: { id: 'scratch', last_active: NOW / 1000 - 10 }
}
assert.equal(botActivitySession(bot).id, 'scratch')
Expand All @@ -107,7 +107,7 @@ test('botActivitySession keeps last_session when it is the fresher one', () => {
test('botActivitySession degrades to whichever side exists (older gateways / no pin)', () => {
const botActivitySession = loadBotActivitySession()
assert.equal(botActivitySession({ last_session: { id: 'only', last_active: 1 } }).id, 'only')
assert.equal(botActivitySession({ preferred_session: { id: 'pin', last_active: 1 } }).id, 'pin')
assert.equal(botActivitySession({ canonical_session: { id: 'pin', last_active: 1 } }).id, 'pin')
assert.equal(botActivitySession({}), null)
assert.equal(botActivitySession(null), null)
})
Expand All @@ -117,7 +117,7 @@ test('activeBots counts Bot Chat activity that last_session cannot see', () => {
const bots = [
{
name: 'default',
preferred_session: { last_active: NOW / 1000 - 5 },
canonical_session: { last_active: NOW / 1000 - 5 },
last_session: { last_active: NOW / 1000 - 6 * 86400 }
}
]
Expand Down Expand Up @@ -179,7 +179,6 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac
// a list key; a `key:` prop leaves chips unkeyed (index identity).
assert.match(source, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s)
assert.match(source, /jsx\(BotFace,\s*\{[\s\S]*?mood: 'work'/)
assert.match(source, /let pinnedChat = botRosterMeta\(bot, allMeta\)\?\.chat/)
assert.match(source, /await prepareBotSource\(bot, pinnedChat\)/)
assert.match(source, /bot\.preferred_session \|\| bot\.last_session/)
assert.match(source, /await prepareBotSource\(bot\)/)
assert.match(source, /bot\.canonical_session \|\| last/)
})
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,13 @@ test('pref defaults OFF and persists via ctx.storage under activity-toasts', ()

test('activity in the hidden canonical Bot Chat still badges (the "6d ago" class)', () => {
// The canonical Bot Chat is hidden from session lists, so last_session
// never advances when a DM lands there — only preferred_session does.
// never advances when a DM lands there — only canonical_session does.
const t = loadTracker(false)
const at = ts => [
{
name: 'researcher',
last_session: { last_active: 100, preview: 'ancient scratch chat' },
preferred_session: { last_active: ts, preview: 'Message from writer: hi' }
canonical_session: { last_active: ts, preview: 'Message from writer: hi' }
}
]
t.trackInboundActivity(at(150)) // seeding poll
Expand Down

This file was deleted.

Loading
Loading