diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index d17fcf236587..f55d99304e58 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -3384,17 +3384,24 @@ async function openStoredBotChat(name, storedId, summary) { /** Adopt-before-mint: the profile may already own a canonical Bot Chat that * the pin lost track of (pin cleared during an outage, ui_meta rolled back, * a fork squatting the title). The core UNIQUE title index guarantees at - * most ONE session titled "Bot Chat" per profile db, so a title scan is an - * exact registry lookup, not a heuristic. Minting while a "Bot Chat" row - * exists is always wrong twice over: it forks the forever-chat AND the new - * row can never take the (already held) canonical title, so the next - * identity check misreads it and forks again — the infinite-fork loop. - * include_hidden is required (canonical chats are always hidden); an older - * gateway without it simply finds nothing and we fall through to mint. */ + * most ONE session titled "Bot Chat" per profile db — Profile → Named + * Session is an exact registry, so consult it exactly: `title` asks the + * gateway for an indexed WHERE title = ? lookup (window-free; a busy + * profile can push the forever-chat past any recency window, which would + * re-open the fork loop with a higher trigger threshold). Minting while a + * "Bot Chat" row exists is always wrong twice over: it forks the + * forever-chat AND the new row can never take the (already held) canonical + * title, so the next identity check misreads it and forks again — the + * infinite-fork loop. An older gateway ignores the unknown `title` param + * and returns the plain windowed listing instead — the pre-exact-lookup + * behavior — so the local scan below stays as the compatibility rung. + * include_hidden is required (canonical chats are always hidden); a gateway + * without it simply finds nothing and we fall through to mint. */ async function findExistingCanonicalChat(name) { try { const res = await host.request('session.list', { profile: name, + title: 'Bot Chat', limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }) @@ -3424,7 +3431,10 @@ function createCanonicalChat(name) { saveBotMeta(name, { chat: existing.id }) if (typeof host.openSession === 'function') { - await openStoredBotChat(name, existing.id, existing) + // The exact-lookup gateway reports the compression-lineage tip as + // resolved_id; the pin stays the durable row id (same split the + // preferred_session path uses). + await openStoredBotChat(name, existing.resolved_id || existing.id, existing) } return existing.id diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 29ebbf332edc..564271ea130c 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -177,6 +177,49 @@ def _(rid, params: dict) -> dict: # their own source. deny = frozenset({"kanban", "tool"}) + # ``title``: EXACT-title registry lookup, not a listing. The core + # UNIQUE title index means at most one session per db carries a + # given exact title, so callers that treat a title as an identity + # key (Bot Mode's canonical "Bot Chat" — Profile → Named Session) + # get a window-free O(1) answer instead of scanning a recency + # window that a busy profile can push the row out of. Hidden rows + # resolve (canonical chats are born hidden); archived rows and + # deny-listed sources do not; compression lineages resolve to the + # live tip (``resolved_id``), mirroring profiles.list's + # preferred_session resolver. Older clients never send this param; + # newer clients falling back to older gateways just get the normal + # windowed listing back (the param is ignored) and scan it. + title_lookup = str(params.get("title") or "").strip() + if title_lookup: + row = db.get_session_by_title(title_lookup) + if ( + not row + or row.get("archived") + or (row.get("source") or "").strip().lower() in deny + ): + return _ok(rid, {"sessions": []}) + try: + tip = db.resolve_resume_session_id(row["id"]) or row["id"] + except Exception: + tip = row["id"] + tip_row = (db.get_session(tip) or row) if tip != row["id"] else row + return _ok( + rid, + { + "sessions": [ + { + "id": row["id"], + "resolved_id": tip, + "title": row.get("title") or "", + "preview": tip_row.get("preview") or "", + "started_at": row.get("started_at") or 0, + "message_count": tip_row.get("message_count") or 0, + "source": row.get("source") or "", + } + ] + }, + ) + limit = int(params.get("limit", 200) or 200) # ``include_hidden``: surfaces that OWN hidden sessions (the Bots # pane's per-profile browser, plugin session pickers) need to list