3d81650c2f
The sidebar reports a profile it could not scan as HTTP 200 with an empty
page and errors=[{profile}]. The renderer merges that page keeping only
working, pinned, and selected rows, so every idle Yesterday / This-week
session disappears until a later scan succeeds — and the 5s coalescing cache
then serves the same empty payload back for the rest of its TTL.
Carry the previous rows forward for exactly the profiles named in errors[],
keyed by profile::id so a twin id in another profile is never stitched in.
Profiles that scanned cleanly are still authoritative, so a genuinely empty
page with no errors still clears the list. Per-profile usage and truncation
flags follow the same rule rather than zeroing under a list that was kept.
The legacy per-slice fallback stamps errors on the slice that actually
failed, so a cron read failure can no longer blank recents.
Part of #73847
Part of #88528
Co-authored-by: AKAZIK-py <AKAZIK-py@users.noreply.github.com>
600 lines
23 KiB
TypeScript
600 lines
23 KiB
TypeScript
import { isMissingRestEndpoint } from '@/lib/gateway-rpc'
|
|
import { maybeBackfillLegacySessionOwners } from '@/lib/legacy-session-owner-backfill'
|
|
import { stampRowsWithOwningConnection } from '@/lib/session-owner-stamp'
|
|
import { recordTranscriptTail } from '@/store/transcript-tail'
|
|
import type {
|
|
PaginatedSessions,
|
|
SessionInfo,
|
|
SessionMessage,
|
|
SessionMessagesResponse,
|
|
SessionSearchResponse
|
|
} from '@/types/hermes'
|
|
|
|
import { capabilityScoped, getApiRequestConnection, hermesApi, type ProfileScope, profileScoped } from './client'
|
|
|
|
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
|
|
|
|
function sessionScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
|
|
if (scope === undefined || scope === null) {
|
|
return {}
|
|
}
|
|
|
|
const scoped = capabilityScoped(scope)
|
|
|
|
if (typeof scope === 'object' && scope.connectionId?.trim() === 'local') {
|
|
return { ...scoped, connectionId: 'local' }
|
|
}
|
|
|
|
return scoped
|
|
}
|
|
|
|
function sessionScopeQuery(scope?: ProfileScope): string {
|
|
const profile = sessionScoped(scope).profile
|
|
|
|
return profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
|
}
|
|
|
|
/**
|
|
* The active registered gateway owns every row it returns, but its HTTP APIs
|
|
* correctly know nothing about this Desktop-local registry id. Preserve an
|
|
* explicit owner from a multi-source response; otherwise stamp the active
|
|
* non-local source so a later resume cannot fall back to a same-named local
|
|
* profile. Delegates to the canonical row-stamp helper so this stays the ONE
|
|
* write shape for connection_id on backend-returned rows.
|
|
*/
|
|
function stampActiveConnectionOwner(sessions: SessionInfo[]): SessionInfo[] {
|
|
// Durable half of the same ownership contract (#94724): enumeration under
|
|
// registry topology triggers the one-shot server-side owner backfill for
|
|
// the serving store when its owner is a single match. Fire-and-forget;
|
|
// idempotent server-side; never blocks or fails the list that triggered it.
|
|
maybeBackfillLegacySessionOwners()
|
|
|
|
return stampRowsWithOwningConnection(sessions, getApiRequestConnection())
|
|
}
|
|
|
|
/**
|
|
* Trim a page to its window WITHOUT discarding pinned rows.
|
|
*
|
|
* The list endpoints deliberately back-fill pinned conversations past their
|
|
* LIMIT — a pin means "always reachable", so an aged-out pinned chat is
|
|
* appended after the recency window. A plain `slice(0, limit)` throws exactly
|
|
* those rows away again, which is why pins silently stopped rendering past
|
|
* some count: the sidebar could only ever show the pins that happened to fall
|
|
* inside the most-recent page.
|
|
*/
|
|
function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] {
|
|
if (sessions.length <= limit) {
|
|
return sessions
|
|
}
|
|
|
|
const recent = sessions.slice(0, limit)
|
|
|
|
return [...recent, ...sessions.slice(limit).filter(session => session.pinned)]
|
|
}
|
|
|
|
export async function listSessions(
|
|
limit = 40,
|
|
minMessages = 0,
|
|
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
|
order: 'created' | 'recent' = 'recent'
|
|
): Promise<PaginatedSessions> {
|
|
const result = await hermesApi<PaginatedSessions>({
|
|
...profileScoped(),
|
|
path:
|
|
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
|
`&archived=${archived}&order=${order}`,
|
|
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
|
})
|
|
|
|
return {
|
|
...result,
|
|
sessions: pageWindow(stampActiveConnectionOwner(result.sessions), limit),
|
|
offset: 0
|
|
}
|
|
}
|
|
|
|
// Unified, read-only session list aggregated across ALL profiles. Served by the
|
|
// primary backend straight off each profile's state.db — no per-profile backend
|
|
// is spawned. Single-profile users get the same rows as listSessions(), tagged
|
|
// profile="default".
|
|
// Source scoping lets callers split the unified list into independent slices:
|
|
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
|
|
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
|
|
// consumes the whole recents page and starves real conversations.
|
|
export interface SessionSourceFilter {
|
|
source?: string
|
|
excludeSources?: string[]
|
|
}
|
|
|
|
export async function listAllProfileSessions(
|
|
limit = 40,
|
|
minMessages = 0,
|
|
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
|
order: 'created' | 'recent' = 'recent',
|
|
profile: 'all' | (string & {}) = 'all',
|
|
filter: SessionSourceFilter = {}
|
|
): Promise<PaginatedSessions> {
|
|
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
|
|
|
|
const excludeParam = filter.excludeSources?.length
|
|
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
|
|
: ''
|
|
|
|
const result = await hermesApi<PaginatedSessions>({
|
|
...profileScoped(),
|
|
path:
|
|
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
|
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
|
|
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
|
})
|
|
|
|
return {
|
|
...result,
|
|
sessions: pageWindow(stampActiveConnectionOwner(result.sessions), limit),
|
|
offset: 0
|
|
}
|
|
}
|
|
|
|
// Batched sidebar slices in one request: recents (scoped to the active profile),
|
|
// cron, and messaging. The backend opens each profile's state.db once and runs
|
|
// all three filtered queries, replacing three separate listAllProfileSessions
|
|
// calls that each reopened + re-counted every profile DB per refresh. Electron
|
|
// splices remote profiles per slice (see interceptSessionRequestForRemote).
|
|
export interface SidebarSessionSlice {
|
|
sessions: SessionInfo[]
|
|
/** Per-profile "the window came back full, more rows exist on disk" flags —
|
|
* what pagination needs, without a COUNT(*) per profile DB per refresh. */
|
|
profiles_truncated?: Record<string, boolean>
|
|
/** Per-profile tokens and spend over every session, not just this window.
|
|
* Absent from the legacy per-slice endpoint, which has no aggregate. */
|
|
profiles_usage?: Record<string, { cost_usd: number; tokens: number }>
|
|
/** Profiles whose scan for THIS slice failed. Batched `/sidebar` stamps the
|
|
* same profile errors on every slice (one DB open). Legacy per-slice calls
|
|
* stamp only the slice that actually failed, so a cron I/O error cannot
|
|
* carry-forward recents. */
|
|
errors?: Array<{ profile: string; error: string }>
|
|
}
|
|
|
|
/** Which profiles filled their per-profile window in a returned page. The
|
|
* legacy per-slice endpoint doesn't report this, so derive it from the rows:
|
|
* a profile at (or over) the cap still has more on disk. Pinned rows are
|
|
* discounted — they're back-filled past the LIMIT, so counting them fakes a
|
|
* full page and leaves a "Load more" that can never resolve. */
|
|
function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record<string, boolean> {
|
|
const counts = new Map<string, number>()
|
|
|
|
for (const session of sessions) {
|
|
const key = session.profile || 'default'
|
|
|
|
counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1))
|
|
}
|
|
|
|
return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap]))
|
|
}
|
|
|
|
export interface SidebarSessionsResponse {
|
|
recents: SidebarSessionSlice
|
|
cron: SidebarSessionSlice
|
|
messaging: SidebarSessionSlice
|
|
errors?: Array<{ profile: string; error: string }>
|
|
}
|
|
|
|
export interface SidebarSessionsRequest {
|
|
recentsProfile: 'all' | (string & {})
|
|
recentsLimit: number
|
|
recentsExclude: string[]
|
|
cronLimit: number
|
|
messagingLimit: number
|
|
messagingExclude: string[]
|
|
}
|
|
|
|
// The batched /sidebar endpoint shipped later than the per-slice route, so a
|
|
// newer desktop can meet an older backend that 404s it ("No such API
|
|
// endpoint"). Endpoint-missing is a capability signal, not a transient
|
|
// failure: remember it (per renderer lifetime — a runtime home change reloads
|
|
// the window and re-probes) and serve every subsequent refresh straight from
|
|
// the three proven per-slice calls instead of re-probing a known-dead route
|
|
// once per turn/broadcast.
|
|
let sidebarBatchEndpointMissing = false
|
|
|
|
// Capability flags are per-backend facts. A hard re-home reloads the window
|
|
// (module state resets naturally), but a soft gateway switch re-dials in
|
|
// place — the next backend may well have the batched route, so the switch
|
|
// paths call this to re-probe rather than leak the old backend's capability.
|
|
export function resetSidebarBatchCapability() {
|
|
sidebarBatchEndpointMissing = false
|
|
}
|
|
|
|
// Compatibility fallback: reassemble the three sidebar slices from the
|
|
// per-slice endpoint, mirroring the batched route's semantics (min_messages=1,
|
|
// archived excluded, recency order; every slice scoped to the caller's profile).
|
|
// Rides the same Electron remote-splice
|
|
// interception as the pre-batching desktop, so remote profiles stay correct.
|
|
async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
|
|
const [recents, cron, messaging] = await Promise.all([
|
|
listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, {
|
|
excludeSources: req.recentsExclude
|
|
}),
|
|
listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }),
|
|
listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, {
|
|
excludeSources: req.messagingExclude
|
|
})
|
|
])
|
|
|
|
const recentsErrors = recents.errors ?? []
|
|
const cronErrors = cron.errors ?? []
|
|
const messagingErrors = messaging.errors ?? []
|
|
|
|
return {
|
|
recents: {
|
|
profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit),
|
|
sessions: recents.sessions,
|
|
...(recentsErrors.length ? { errors: recentsErrors } : {})
|
|
},
|
|
cron: {
|
|
sessions: cron.sessions,
|
|
...(cronErrors.length ? { errors: cronErrors } : {})
|
|
},
|
|
messaging: {
|
|
sessions: messaging.sessions,
|
|
...(messagingErrors.length ? { errors: messagingErrors } : {})
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The PR each of these sessions opened, recovered from its own transcript —
|
|
* for sessions whose recorded branch can't answer (they started on trunk and
|
|
* did the work in a worktree). Also returns every id it looked at, so the
|
|
* caller can remember a miss and never ask again. */
|
|
export function scanSessionPullRequests(
|
|
ids: string[]
|
|
): Promise<{ pull_requests: Record<string, { number: number; url: string }>; scanned: string[] }> {
|
|
return hermesApi<{
|
|
pull_requests: Record<string, { number: number; url: string }>
|
|
scanned: string[]
|
|
}>({
|
|
path: '/api/profiles/sessions/pull-requests',
|
|
method: 'POST',
|
|
body: { ids }
|
|
})
|
|
}
|
|
|
|
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
|
|
if (sidebarBatchEndpointMissing) {
|
|
return listSidebarSessionsLegacy(req)
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
recents_profile: req.recentsProfile,
|
|
recents_limit: String(Math.max(1, req.recentsLimit)),
|
|
cron_limit: String(Math.max(1, req.cronLimit)),
|
|
messaging_limit: String(Math.max(1, req.messagingLimit))
|
|
})
|
|
|
|
if (req.recentsExclude.length) {
|
|
params.set('recents_exclude', req.recentsExclude.join(','))
|
|
}
|
|
|
|
if (req.messagingExclude.length) {
|
|
params.set('messaging_exclude', req.messagingExclude.join(','))
|
|
}
|
|
|
|
let result: SidebarSessionsResponse
|
|
|
|
try {
|
|
result = await hermesApi<SidebarSessionsResponse>({
|
|
...profileScoped(),
|
|
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
|
|
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
|
})
|
|
} catch (err) {
|
|
// Safe to read a 404 as route-missing here: this GET has no path params,
|
|
// so it cannot 404 on a bad id.
|
|
if (!isMissingRestEndpoint(err)) {
|
|
throw err
|
|
}
|
|
|
|
// Older backend without the batched route (desktop/runtime version skew).
|
|
sidebarBatchEndpointMissing = true
|
|
|
|
return listSidebarSessionsLegacy(req)
|
|
}
|
|
|
|
return {
|
|
recents: {
|
|
...result.recents,
|
|
sessions: stampActiveConnectionOwner(result.recents?.sessions ?? []),
|
|
...(result.errors?.length ? { errors: result.errors } : {})
|
|
},
|
|
cron: {
|
|
...result.cron,
|
|
sessions: stampActiveConnectionOwner(result.cron?.sessions ?? []),
|
|
...(result.errors?.length ? { errors: result.errors } : {})
|
|
},
|
|
messaging: {
|
|
...result.messaging,
|
|
sessions: stampActiveConnectionOwner(result.messaging?.sessions ?? []),
|
|
...(result.errors?.length ? { errors: result.errors } : {})
|
|
},
|
|
errors: result.errors
|
|
}
|
|
}
|
|
|
|
// Mutations take the owning `profile` so Electron can route them to the correct
|
|
// remote backend or local profile scope. Omit for the current/default profile.
|
|
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
|
// Carry the owning profile IN THE PATCH BODY, mirroring renameSession — the
|
|
// backend reads its target DB from body.profile (_open_session_db_for_profile).
|
|
// Passing it only as request.profile (Electron routing) is not enough on a
|
|
// remote gateway with no remoteProfile alias: the archive lands on the wrong
|
|
// (default) state.db, no-ops on a missing row, and the archived/unarchived
|
|
// state silently fails to stick — the same class as the unscoped DELETE.
|
|
return hermesApi<{ ok: boolean }>({
|
|
...(profile ? { profile } : {}),
|
|
path: `/api/sessions/${encodeURIComponent(id)}`,
|
|
method: 'PATCH',
|
|
body: { archived, ...(profile ? { profile } : {}) }
|
|
})
|
|
}
|
|
|
|
// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive
|
|
// sweep (which runs backend-side, blind to Desktop localStorage) never hides a
|
|
// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own
|
|
// display; this only feeds the backend policy.
|
|
export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
|
// Owning profile in the PATCH body (see setSessionArchived / renameSession):
|
|
// the handler reads its target DB from body.profile, so a remote/foreign
|
|
// profile's pin must travel in the body or it no-ops on the wrong state.db.
|
|
return hermesApi<{ ok: boolean }>({
|
|
...(profile ? { profile } : {}),
|
|
path: `/api/sessions/${encodeURIComponent(id)}`,
|
|
method: 'PATCH',
|
|
body: { pinned, ...(profile ? { profile } : {}) }
|
|
})
|
|
}
|
|
|
|
// Mirror a sidebar unread toggle to the backend read-state watermark
|
|
// (sessions.last_read_at via SessionDB.set_session_read). Same profile
|
|
// routing as the other session mutations: a remote session's row lives only
|
|
// on its remote host, so the owning profile must travel with the request.
|
|
export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
|
// Owning profile in the PATCH body (see setSessionArchived / renameSession):
|
|
// the handler reads its target DB from body.profile, so a remote/foreign
|
|
// profile's unread toggle must travel in the body or it no-ops on the wrong
|
|
// state.db.
|
|
return hermesApi<{ ok: boolean }>({
|
|
...(profile ? { profile } : {}),
|
|
path: `/api/sessions/${encodeURIComponent(id)}`,
|
|
method: 'PATCH',
|
|
body: { unread, ...(profile ? { profile } : {}) }
|
|
})
|
|
}
|
|
|
|
export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
|
return hermesApi<SessionSearchResponse>({
|
|
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
|
|
})
|
|
}
|
|
|
|
// Resolves a single session row by id on one backend (the active profile, or
|
|
// the given `profile`). The backend resolves exact ids and unique prefixes and
|
|
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
|
|
// cross-profile list scan when locating an unknown id's owner.
|
|
export function getSession(id: string, profile?: ProfileScope): Promise<SessionInfo> {
|
|
const suffix = sessionScopeQuery(profile)
|
|
|
|
return hermesApi<SessionInfo>({
|
|
...sessionScoped(profile),
|
|
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
|
|
})
|
|
}
|
|
|
|
// Reads another profile's transcript. For a remote profile Electron reroutes
|
|
// this GET to the remote backend (which serves its own state.db); for a local
|
|
// profile the primary opens that profile's state.db via ?profile=. Omit for
|
|
// the current/default profile.
|
|
export function getSessionMessages(
|
|
id: string,
|
|
profile?: ProfileScope,
|
|
page: { limit?: number; offset?: number; order?: 'latest' | 'oldest'; includeCompacted?: boolean } = {}
|
|
): Promise<SessionMessagesResponse> {
|
|
const query = new URLSearchParams()
|
|
|
|
const sessionScope = sessionScoped(profile)
|
|
|
|
if (sessionScope.profile) {
|
|
query.set('profile', sessionScope.profile)
|
|
}
|
|
|
|
if (page.limit !== undefined) {
|
|
query.set('limit', String(page.limit))
|
|
}
|
|
|
|
if (page.offset !== undefined) {
|
|
query.set('offset', String(page.offset))
|
|
}
|
|
|
|
if (page.order) {
|
|
query.set('order', page.order)
|
|
}
|
|
|
|
if (page.includeCompacted !== undefined) {
|
|
query.set('include_compacted', String(page.includeCompacted))
|
|
}
|
|
|
|
const suffix = query.size ? `?${query.toString()}` : ''
|
|
|
|
return hermesApi<SessionMessagesResponse>({
|
|
...sessionScope,
|
|
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The initial hydration page: enough tail to fill the transcript window a few
|
|
* times over, small enough that opening a long session doesn't ship (and
|
|
* convert) hundreds of rows nobody has scrolled to. Older rows load on demand
|
|
* via `getOlderSessionMessages` when "Show earlier" exhausts the in-memory
|
|
* store (see app/chat/transcript-backfill).
|
|
*/
|
|
export const LATEST_SESSION_MESSAGES_LIMIT = 120
|
|
|
|
export function getLatestSessionMessages(id: string, profile?: ProfileScope): Promise<SessionMessagesResponse> {
|
|
// includeCompacted: durable display history must include rows preserved by
|
|
// in-place compaction (active=0, compacted=1); without them the transcript
|
|
// silently ends at the compaction boundary and earlier turns are unreachable.
|
|
return getSessionMessages(id, profile, {
|
|
limit: LATEST_SESSION_MESSAGES_LIMIT,
|
|
order: 'latest',
|
|
includeCompacted: true
|
|
}).then(page => {
|
|
// Record whether the tail was truncated (page came back full) and where
|
|
// the next older page starts, so "Show earlier" can backfill over REST
|
|
// (app/chat/transcript-backfill). Keyed under both the requested id and
|
|
// the resolved id — callers hold either.
|
|
recordTranscriptTail(id, page, profile)
|
|
|
|
if (page.session_id && page.session_id !== id) {
|
|
recordTranscriptTail(page.session_id, page, profile)
|
|
}
|
|
|
|
return page
|
|
})
|
|
}
|
|
|
|
/**
|
|
* READ-ONLY stored-transcript lookup that never routes a live session
|
|
* (#94724 no-owner recovery). Tries the ambient/primary store first, then
|
|
* probes every registered NON-local connection by id — a REST read of a
|
|
* backend's own state.db is side-effect free (a miss is a plain 404, no
|
|
* session is minted or resumed anywhere), so probing across backends is safe
|
|
* where live routing would be a guess. Returns null when no reachable
|
|
* backend holds the transcript.
|
|
*/
|
|
export async function fetchStoredTranscriptAcrossBackends(id: string): Promise<SessionMessagesResponse | null> {
|
|
try {
|
|
return await getLatestSessionMessages(id)
|
|
} catch {
|
|
// Not on the ambient store — probe the registered backends below.
|
|
}
|
|
|
|
const { $connectionsRegistry } = await import('@/store/connection-registry-state')
|
|
|
|
const connections = ($connectionsRegistry.get()?.connections ?? []) as Array<{ id?: string }>
|
|
|
|
for (const connection of connections) {
|
|
const connectionId = connection.id?.trim()
|
|
|
|
if (!connectionId || connectionId === 'local' || connectionId === getApiRequestConnection()) {
|
|
continue
|
|
}
|
|
|
|
try {
|
|
return await getLatestSessionMessages(id, { connectionId, profile: 'default' })
|
|
} catch {
|
|
// Not on this backend (or it is unreachable); try the next.
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* One page of messages OLDER than the `offset` newest rows.
|
|
*
|
|
* Backend semantics (`_handle_session_messages` → `SessionDB.get_messages`
|
|
* with `latest=True`): the offset is measured back from the NEWEST message
|
|
* and the selected page is returned in chronological order. So after a tail
|
|
* hydration of N rows, `getOlderSessionMessages(id, profile, N)` returns the
|
|
* page immediately preceding it, ready to prepend.
|
|
*
|
|
* Legacy backends without pagination support return the full transcript and
|
|
* no `pagination` metadata — callers detect that via the missing field and
|
|
* treat the response as the complete history (see transcript-backfill).
|
|
*/
|
|
export function getOlderSessionMessages(
|
|
id: string,
|
|
profile: ProfileScope,
|
|
offset: number,
|
|
limit: number = LATEST_SESSION_MESSAGES_LIMIT
|
|
): Promise<SessionMessagesResponse> {
|
|
return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' })
|
|
}
|
|
|
|
export async function getAllSessionMessages(
|
|
id: string,
|
|
profile?: ProfileScope,
|
|
options: { maxJsonChars?: number } = {}
|
|
): Promise<SessionMessagesResponse> {
|
|
const messages: SessionMessage[] = []
|
|
const pageSize = 500
|
|
const maxJsonChars = options.maxJsonChars ?? 32_000_000
|
|
let jsonChars = 0
|
|
let offset = 0
|
|
let resolvedSessionId = id
|
|
|
|
while (true) {
|
|
const page = await getSessionMessages(id, profile, {
|
|
limit: pageSize,
|
|
offset,
|
|
order: 'oldest',
|
|
includeCompacted: true
|
|
})
|
|
|
|
resolvedSessionId = page.session_id
|
|
jsonChars += (JSON.stringify(page.messages) ?? '').length
|
|
|
|
if (jsonChars > maxJsonChars) {
|
|
throw new Error(
|
|
'Session transcript exceeds the Desktop safe-load limit; use the Web Dashboard export for this session.'
|
|
)
|
|
}
|
|
|
|
messages.push(...page.messages)
|
|
|
|
// Legacy backends ignore pagination and return the full transcript.
|
|
if (!page.pagination || page.messages.length === 0 || page.messages.length < page.pagination.limit) {
|
|
break
|
|
}
|
|
|
|
offset += page.messages.length
|
|
}
|
|
|
|
return { session_id: resolvedSessionId, messages }
|
|
}
|
|
|
|
export function deleteSession(id: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
|
|
// Scope the DELETE to the owning profile IN THE URL, mirroring getSession /
|
|
// getSessionMessages. Passing the profile only via request.profile (which the
|
|
// Electron main process consumes for backend routing) is NOT enough: on a
|
|
// remote gateway whose connection has no remoteProfile alias, the main-process
|
|
// path rewrite leaves the URL unscoped, so the backend opens its OWN (default)
|
|
// state.db, fails to find another profile's session, and returns
|
|
// {ok:true, already_absent:true}. The row then vanishes optimistically but was
|
|
// never deleted and reappears on the next sidebar refresh — the "All Profiles
|
|
// delete doesn't stick" report. The endpoint already honours ?profile=
|
|
// (get/rename/messages all send it); DELETE was the one mutation that dropped
|
|
// it after the api/ split. request.profile stays on for per-profile remote
|
|
// override + global-remote routing (both re-read/re-append the param).
|
|
const suffix = sessionScopeQuery(profile)
|
|
|
|
return hermesApi<{ ok: boolean }>({
|
|
...sessionScoped(profile),
|
|
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`,
|
|
method: 'DELETE'
|
|
})
|
|
}
|
|
|
|
export function renameSession(
|
|
id: string,
|
|
title: string,
|
|
profile?: string | null
|
|
): Promise<{ ok: boolean; title: string }> {
|
|
return hermesApi<{ ok: boolean; title: string }>({
|
|
...(profile ? { profile } : {}),
|
|
path: `/api/sessions/${encodeURIComponent(id)}`,
|
|
method: 'PATCH',
|
|
body: { title, ...(profile ? { profile } : {}) }
|
|
})
|
|
}
|