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
26 changes: 18 additions & 8 deletions apps/desktop/src/plugins/hermes-bots/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions tui_gateway/methods_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading