import { JsonRpcGatewayClient } from '@hermes/shared' import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' import type { ActionResponse, ActionStatusResponse, AnalyticsResponse, AudioSpeakResponse, AudioTranscriptionResponse, AutomationBlueprint, AuxiliaryModelsResponse, BackendUpdateCheckResponse, ComputerUseStatus, ConfigSchemaResponse, CronDeliveryTarget, CronJob, CronJobCreatePayload, CronJobUpdates, CuratorStatusResponse, CustomEndpointsResponse, CustomEndpointUpdate, CustomEndpointValidationResponse, DebugShareResponse, ElevenLabsVoicesResponse, EnvVarInfo, HermesConfig, HermesConfigRecord, LogsResponse, McpCatalogResponse, McpServerSummary, MemoryProviderConfig, MemoryProviderOAuthStatus, MemoryStatusResponse, MessagingPlatformsResponse, MessagingPlatformTestResponse, MessagingPlatformUpdate, MoaConfigResponse, ModelAssignmentRequest, ModelAssignmentResponse, ModelInfoResponse, ModelOptionsResponse, OAuthPollResponse, OAuthProvidersResponse, OAuthStartResponse, OAuthSubmitResponse, PaginatedSessions, PairingResponse, PairingUser, ProfileCreatePayload, ProfileDesktopOverlay, ProfileSetupCommand, ProfileSoul, ProfilesResponse, SessionInfo, SessionMessage, SessionMessagesResponse, SessionSearchResponse, SkillHubPreview, SkillHubScanResult, SkillHubSearchResponse, SkillHubSourcesResponse, SkillInfo, StarmapGraph, StatusResponse, TerminalBackendsResponse, ToolsetConfig, ToolsetInfo, ToolsetModelsResponse, WebhookCreatePayload, WebhookCreateResponse, WebhookEnableResponse, WebhooksResponse } from '@/types/hermes' // Desktop startup fires a burst of read-only data calls (config, profiles, // model info/options, cron) the moment the backend passes readiness. On a // profile-heavy or remote install these can each take tens of seconds — e.g. // /api/profiles runs list_profiles(), which does a recursive skill-tree walk // per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts) // times out a backend that is alive-but-busy, surfacing as a spurious // "Timed out connecting to Hermes backend" that hangs the UI (#48504). // // Give the boot burst a generous per-call timeout instead of raising the // global default: interactive/runtime calls and the liveness poll (/api/status) // keep the short default so a genuinely-dead backend is still detected fast. export const STARTUP_REQUEST_TIMEOUT_MS = 60_000 const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000 const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 // prompt.submit is effectively fire-and-forget: turn completion is signaled by // stream / message.complete events, NOT by the RPC return. A long turn (MoA // presets running references + aggregator in series, deep reasoning, large tool // chains) can legitimately take minutes to ACK, so bounding the ack by the // generic 30s default surfaces a false "request timed out" toast while the turn // is still running and will succeed (issue #55024). Match the backend's // agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only // ever fires when the turn itself would have been abandoned server-side. export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000 export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000 export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000 const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35 export function audioSpeakRequestTimeoutMs(text: string): number { const estimated = Math.max( AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR) ) return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated) } export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000 export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000 // The transcribe payload is the base64 audio data URL itself, so its string // length tracks clip size. ~0.1ms/char keeps short clips at the floor while // letting multi-minute recordings scale toward the cap (a base64 char is // ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of // timeout per 1s of audio before the cap clamps it). const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1 export function audioTranscribeRequestTimeoutMs(dataUrl: string): number { const estimated = Math.max( AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR) ) return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated) } export type { ActionResponse, ActionStatusResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsResponse, AnalyticsSkillEntry, AnalyticsSkillsSummary, AnalyticsTotals, AudioSpeakResponse, AudioTranscriptionResponse, AutomationBlueprint, AutomationBlueprintField, AuxiliaryModelsResponse, BackendUpdateCheckResponse, ComputerUseCheck, ComputerUsePermissionSource, ComputerUseStatus, ConfigFieldSchema, ConfigSchemaResponse, CronDeliveryTarget, CronJob, CronJobCreatePayload, CronJobSchedule, CronJobUpdates, CuratorStatusResponse, CustomEndpoint, CustomEndpointsResponse, CustomEndpointUpdate, CustomEndpointValidationResponse, DebugShareResponse, ElevenLabsVoice, ElevenLabsVoicesResponse, EnvVarInfo, GatewayReadyPayload, HermesConfig, HermesConfigRecord, LogsResponse, McpCatalogEntry, McpCatalogResponse, McpServerSummary, McpServerTestResponse, MemoryProviderConfig, MemoryProviderOAuthStatus, MemoryStatusResponse, MessagingEnvVarInfo, MessagingHomeChannel, MessagingPlatformInfo, MessagingPlatformsResponse, MessagingPlatformTestResponse, MessagingPlatformUpdate, MoaConfigResponse, MoaModelSlot, ModelAssignmentRequest, ModelAssignmentResponse, ModelInfoResponse, ModelOptionProvider, ModelOptionsResponse, PaginatedSessions, PairingResponse, PairingUser, ProfileCreatePayload, ProfileDesktopOverlay, ProfileInfo, ProfileSetupCommand, ProfileSoul, ProfilesResponse, ProjectFolder, ProjectInfo, ProjectsPayload, RpcEvent, SessionCreateResponse, SessionInfo, SessionMessage, SessionMessagesResponse, SessionResumeResponse, SessionRuntimeInfo, SessionSearchResponse, SessionSearchResult, SkillHubInstalledEntry, SkillHubPreview, SkillHubResult, SkillHubScanResult, SkillHubSearchResponse, SkillHubSource, SkillHubSourcesResponse, SkillInfo, StaleAuxAssignment, StarmapGraph, StatusResponse, ToolsetConfig, ToolsetInfo, ToolsetModel, ToolsetModelsResponse, WebhookCreatePayload, WebhookCreateResponse, WebhookEnableResponse, WebhookRoute, WebhooksResponse } from '@/types/hermes' export class HermesGateway extends JsonRpcGatewayClient { constructor() { super({ closedErrorMessage: 'Hermes gateway connection closed', connectErrorMessage: 'Could not connect to Hermes gateway', createRequestId: nextId => nextId, notConnectedErrorMessage: 'Hermes gateway is not connected', requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }) } } // Profile that profile-scoped REST settings (config/env/skills/tools/model/…) // should target. Mirrors $activeGatewayProfile, pushed in from the store via // setApiRequestProfile so this module needs no store import (avoids a cycle). // Electron main consumes request.profile to pick which backend *process* serves // the call; each pooled backend already has its own HERMES_HOME, so no backend // change is needed. Null → primary, so single-profile users are unaffected. let _apiProfile: null | string = null export function setApiRequestProfile(profile: null | string): void { _apiProfile = profile || null } function profileScoped(profile?: null | string): { profile?: string } { const selected = profile === undefined ? _apiProfile : profile return selected ? { profile: selected } : {} } /** Profile that profile-scoped REST/WS calls should target (null → primary). * Read-only twin of setApiRequestProfile for modules (e.g. voice playback) * that build their own connection URLs and must stay on the same backend. */ export function getApiRequestProfile(): null | string { return _apiProfile } /** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api` * shape, minus the path (which is namespace-derived). */ export interface PluginRestOptions { method?: string body?: unknown /** Single-file multipart upload (see HermesApiRequest.upload). */ upload?: { filename: string; contentType?: string; bytes: ArrayBuffer } timeoutMs?: number } // Normalize `path` to a leading-slash suffix relative to `/api/plugins/`. // The namespace is the boundary — reject `..` so a relative segment can't // normalize out into another plugin's API or a core route. Check the path // portion only (before any query/hash). function pluginPathSuffix(caller: string, path: string): string { const suffix = path.startsWith('/') ? path : `/${path}` if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) { throw new Error(`${caller}: illegal path traversal in "${path}"`) } return suffix } /** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's * own backend namespace — `path` is relative to `/api/plugins/` * ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another * plugin's API or a core route through it. Profile-aware like every desktop * REST call. Broader reach (core endpoints, another namespace) is the future * declared-capability seam; today the namespace IS the boundary. */ export async function pluginRest(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise { if (!window.hermesDesktop?.api) { throw new Error('Hermes desktop bridge unavailable') } const suffix = pluginPathSuffix('pluginRest', path) return window.hermesDesktop.api({ path: `/api/plugins/${pluginId}${suffix}`, method: opts.method, body: opts.body, upload: opts.upload, timeoutMs: opts.timeoutMs, ...profileScoped() }) } /** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same * way: `path` is relative to `/api/plugins/` ('/events' → the * plugin's own event stream). Token-mode backends auth via the same query * credential the app's own sockets use; OAuth remotes resolve null (callers * keep their polling fallback — every consumer must have one anyway, since a * socket can drop). Auto-reconnects with backoff until disposed. */ export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void { const suffix = pluginPathSuffix('pluginSocket', path) let socket: null | WebSocket = null let disposed = false let attempt = 0 const connect = async () => { const connection = await window.hermesDesktop.getConnection().catch(() => null) // No bridge / OAuth cookie auth (WS tickets are single-use, core-managed): // stay on the polling fallback rather than half-working. if (disposed || !connection || connection.authMode === 'oauth') { return } const base = connection.baseUrl.replace(/^http/, 'ws') const join = suffix.includes('?') ? '&' : '?' socket = new WebSocket( `${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}` ) socket.onmessage = event => { attempt = 0 try { onMessage(JSON.parse(String(event.data))) } catch { // Non-JSON frame — plugin streams are JSON by contract; skip it. } } socket.onclose = () => { socket = null if (!disposed) { // Full-jitter exponential backoff: same rationale as the gateway // socket reconnect loops — an immediate-retry loop across many // desktop clients floods the gateway with connection attempts // during a restart. window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 })) attempt += 1 } } } void connect() return () => { disposed = true socket?.close() } } /** * 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 { const result = await window.hermesDesktop.api({ 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(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 { const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : '' const excludeParam = filter.excludeSources?.length ? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}` : '' const result = await window.hermesDesktop.api({ 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(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 } /** 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 { const counts = new Map() 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 } // True only for "the route does not exist on this backend" shapes: the // backend catch-all ('404: {"detail":"No such API endpoint: ...}'), FastAPI's // bare 404 on headless serve (surfaces as '404: ...' directly or as // "Error invoking remote method 'hermes:api': Error: 404: ..." through the // IPC bridge), and the Electron JSON-guard ("endpoint is likely missing"). // This GET has no path params, so a 404 status can only mean route-missing — // but transient failures (timeouts, 5xx, connection refused) must NOT match, // or one blip would silently degrade the fast path for the whole session. function isEndpointMissingError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err) return ( /no such api endpoint/i.test(message) || /endpoint is likely missing/i.test(message) || /(?:^\s*|error:\s*)404\b/i.test(message) ) } // 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; recents scoped to the caller's profile, // cron + messaging cross-profile). Rides the same Electron remote-splice // interception as the pre-batching desktop, so remote profiles stay correct. async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise { const [recents, cron, messaging] = await Promise.all([ listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, { excludeSources: req.recentsExclude }), listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', 'all', { source: 'cron' }), listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', 'all', { excludeSources: req.messagingExclude }) ]) const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])] return { recents: { profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit), sessions: recents.sessions }, cron: { sessions: cron.sessions }, messaging: { sessions: messaging.sessions }, ...(errors.length ? { errors } : {}) } } export async function listSidebarSessions(req: SidebarSessionsRequest): Promise { 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 window.hermesDesktop.api({ path: `/api/profiles/sessions/sidebar?${params.toString()}`, timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS }) } catch (err) { if (!isEndpointMissingError(err)) { throw err } // Older backend without the batched route (desktop/runtime version skew). sidebarBatchEndpointMissing = true return listSidebarSessionsLegacy(req) } return { recents: { ...result.recents, sessions: result.recents?.sessions ?? [] }, cron: { ...result.cron, sessions: result.cron?.sessions ?? [] }, messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] }, errors: result.errors } } // Mutations take the owning `profile` so Electron routes them to that profile's // backend (remote pool or local primary) via request.profile — matching the // read path. A remote session's row lives only on its remote host, so a mutation // that hit the local primary would no-op or 404. Omit for the current/default. export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', body: { archived } }) } // 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 }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', body: { pinned } }) } export function searchSessions(query: string): Promise { return window.hermesDesktop.api({ 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?: string | null): Promise { const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' return window.hermesDesktop.api({ ...(profile ? { 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?: string | null, page: { limit?: number; offset?: number; order?: 'latest' | 'oldest' } = {} ): Promise { const query = new URLSearchParams() if (profile) { query.set('profile', 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) } const suffix = query.size ? `?${query.toString()}` : '' return window.hermesDesktop.api({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` }) } export function getLatestSessionMessages(id: string, profile?: string | null): Promise { return getSessionMessages(id, profile, { limit: 500, order: 'latest' }) } export async function getAllSessionMessages( id: string, profile?: string | null, options: { maxJsonChars?: number } = {} ): Promise { 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' }) 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?: string | null): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'DELETE' }) } export function renameSession( id: string, title: string, profile?: string | null ): Promise<{ ok: boolean; title: string }> { return window.hermesDesktop.api<{ ok: boolean; title: string }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', body: { title, ...(profile ? { profile } : {}) } }) } export function getGlobalModelInfo(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/model/info', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export function getStatus(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/status' }) } export function getLogs(params: { component?: string file?: string level?: string lines?: number search?: string }): Promise { const query = new URLSearchParams() if (params.file) { query.set('file', params.file) } if (typeof params.lines === 'number') { query.set('lines', String(params.lines)) } if (params.level && params.level !== 'ALL') { query.set('level', params.level) } if (params.component && params.component !== 'all') { query.set('component', params.component) } if (params.search) { query.set('search', params.search) } const suffix = query.toString() return window.hermesDesktop.api({ ...profileScoped(), path: suffix ? `/api/logs?${suffix}` : '/api/logs' }) } export function getHermesConfig(profile?: string): Promise { return window.hermesDesktop.api({ ...profileScoped(profile), path: '/api/config', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export function getHermesConfigRecord(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/config' }) } export function getHermesConfigDefaults(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/config/defaults', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export function getHermesConfigSchema(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/config/schema' }) } export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: '/api/config', method: 'PUT', body: { config } }) } // surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. export function getMemoryProviderConfig(provider: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` }) } export function saveMemoryProviderConfig(provider: string, values: Record): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, method: 'PUT', body: { values } }) } export function getEnvVars(): Promise> { return window.hermesDesktop.api>({ ...profileScoped(), path: '/api/env' }) } export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: '/api/env', method: 'PUT', body: { key, value } }) } export function validateProviderCredential( key: string, value: string, apiKey?: string ): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> { return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({ ...profileScoped(), path: '/api/providers/validate', method: 'POST', body: { key, value, api_key: apiKey ?? '' } }) } export function getCustomEndpoints(): Promise { return window.hermesDesktop.api({ path: '/api/providers/custom-endpoints' }) } export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { return window.hermesDesktop.api({ path: '/api/providers/custom-endpoints', method: 'POST', body: endpoint }) } export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { return window.hermesDesktop.api({ path: '/api/providers/custom-endpoints/validate', method: 'POST', body: endpoint }) } export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> { return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({ path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, method: 'POST' }) } export function deleteCustomEndpoint(id: string): Promise { return window.hermesDesktop.api({ path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, method: 'DELETE' }) } export function deleteEnvVar(key: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: '/api/env', method: 'DELETE', body: { key } }) } export function revealEnvVar(key: string): Promise<{ key: string; value: string }> { return window.hermesDesktop.api<{ key: string; value: string }>({ ...profileScoped(), path: '/api/env/reveal', method: 'POST', body: { key } }) } export function listOAuthProviders(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/providers/oauth' }) } export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> { return window.hermesDesktop.api<{ ok: boolean; provider: string }>({ ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, method: 'DELETE' }) } export function startOAuthLogin(providerId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, method: 'POST', body: {} }) } export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, method: 'POST', body: { session_id: sessionId, code } }) } export function pollOAuthSession(providerId: string, sessionId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` }) } export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, method: 'DELETE' }) } // Memory-provider OAuth connect (provider-keyed; 404s for providers without an // OAuth flow). Profile-scoped: the grant lands in the active profile's config. export function startMemoryProviderOAuth(provider: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`, method: 'POST' }) } export function getMemoryProviderOAuthStatus(provider: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status` }) } export function getSkills(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/skills' }) } export function getStarmapGraph(): Promise { return window.hermesDesktop.api({ ...profileScoped(), // Backend REST contract — stays /api/learning even though the UI feature is // now "star map". Renaming this would break against an un-upgraded backend. path: '/api/learning/graph' }) } export interface LearningNodeDetail { content: string kind: 'memory' | 'skill' label: string ok: boolean } export function getLearningNode(id: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/learning/node?id=${encodeURIComponent(id)}` }) } export function deleteLearningNode(id: string): Promise<{ message: string; ok: boolean }> { return window.hermesDesktop.api<{ message: string; ok: boolean }>({ ...profileScoped(), path: '/api/learning/node', method: 'DELETE', body: { id } }) } export function editLearningNode(id: string, content: string): Promise<{ message: string; ok: boolean }> { return window.hermesDesktop.api<{ message: string; ok: boolean }>({ ...profileScoped(), path: '/api/learning/node', method: 'PUT', body: { content, id } }) } export function setSkillEnabled( name: string, enabled: boolean ): Promise<{ ok: boolean; name: string; enabled: boolean }> { return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ ...profileScoped(), path: '/api/skills/toggle', method: 'PUT', body: { name, enabled } }) } export interface McpTestResult { ok: boolean error?: string tools: { name: string; description: string }[] /** Capability counts (absent on older backends / failed probes). */ prompts?: number resources?: number } export interface McpOAuthFlow { flow_id: string server_name: string status: 'starting' | 'authorization_required' | 'approved' | 'error' authorization_url: string | null error: string | null tools?: { name: string; description: string }[] } /** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes * for real) — well past the 15s default fetch timeout. */ export function testMcpServer(name: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/mcp/servers/${encodeURIComponent(name)}/test`, method: 'POST', timeoutMs: 60_000 }) } /** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike * `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes, * re-enables (dropping `enabled: false`), and removed nested fields persist. */ export function saveMcpServers(servers: Record>): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: '/api/mcp/servers', method: 'PUT', body: { servers } }) } /** Start an MCP OAuth flow and return the authorization URL. */ export function authMcpServer(name: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`, method: 'POST', timeoutMs: 60_000 }) } export function getMcpOAuthFlow(flowId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}` }) } export function getToolsets(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/tools/toolsets' }) } export function setToolsetEnabled( name: string, enabled: boolean ): Promise<{ ok: boolean; name: string; enabled: boolean }> { return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}`, method: 'PUT', body: { enabled } }) } export function getToolsetConfig(name: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}/config` }) } export function getToolsetModels(name: string, provider?: string): Promise { const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : '' return window.hermesDesktop.api({ ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}` }) } export function selectToolsetModel( name: string, model: string, provider?: string ): Promise<{ ok: boolean; name: string; model: string }> { return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({ ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`, method: 'PUT', body: { model, provider } }) } export interface SelectToolsetProviderResponse { ok: boolean name: string provider: string /** Present when the selection was scoped to one web capability. */ capability?: string /** Present (true) when a managed Nous row was selected but the Portal * entitlement is missing — the row won't activate until the user signs * in to Nous Portal. */ needs_nous_auth?: boolean /** The managed feature key (e.g. "browser") when needs_nous_auth is set. */ feature?: string } export function selectToolsetProvider( name: string, provider: string, capability?: 'search' | 'extract' ): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, method: 'PUT', body: capability ? { provider, capability } : { provider } }) } export function runToolsetPostSetup(name: string, key: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, method: 'POST', body: { key } }) } export function getTerminalBackends(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/tools/terminal/backends' }) } export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> { return window.hermesDesktop.api<{ ok: boolean; backend: string }>({ ...profileScoped(), path: '/api/tools/terminal/backend', method: 'PUT', body: { backend } }) } export function getComputerUseStatus(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/tools/computer-use/status' }) } export function grantComputerUsePermissions(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/tools/computer-use/permissions/grant', method: 'POST' }) } export function getMessagingPlatforms(): Promise { return window.hermesDesktop.api({ path: '/api/messaging/platforms' }) } export function updateMessagingPlatform( platformId: string, body: MessagingPlatformUpdate ): Promise<{ ok: boolean; platform: string }> { return window.hermesDesktop.api<{ ok: boolean; platform: string }>({ path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`, method: 'PUT', body }) } export function testMessagingPlatform(platformId: string): Promise { return window.hermesDesktop.api({ path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`, method: 'POST' }) } // -- Pairing (who may DM the bot) -------------------------------------------- // Unknown DMers get a one-time code and land in `pending` until an admin // approves them. Approval grants on the row's `request_id`, never on the code: // the code is the requester's proof that the channel is theirs and is never // returned by the API, while an authenticated admin is only ever identifying // a row they can already see. export function getPairing(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/pairing' }) } export function approvePairing(platform: string, requestId: string): Promise<{ ok: boolean; user: PairingUser }> { return window.hermesDesktop.api<{ ok: boolean; user: PairingUser }>({ ...profileScoped(), path: '/api/pairing/approve', method: 'POST', // These endpoints read the profile off the body, not the query string — // `profileScoped()` alone would approve into the wrong profile's store. body: { platform, request_id: requestId, ...profileScoped() } }) } export function revokePairing(platform: string, userId: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: '/api/pairing/revoke', method: 'POST', body: { platform, user_id: userId, ...profileScoped() } }) } // -- Webhooks (subscription CRUD) -------------------------------------------- // The webhook receiver is its own gateway platform; subscriptions live in a // shared JSON store the CLI/dashboard also drive. Enable mutates config and // best-effort restarts the gateway; subscription changes hot-reload. export function getWebhooks(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/webhooks' }) } export function enableWebhooks(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/webhooks/enable', method: 'POST' }) } export function createWebhook(body: WebhookCreatePayload): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/webhooks', method: 'POST', body }) } export function deleteWebhook(name: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: `/api/webhooks/${encodeURIComponent(name)}`, method: 'DELETE' }) } export function setWebhookEnabled( name: string, enabled: boolean ): Promise<{ enabled: boolean; name: string; ok: boolean }> { return window.hermesDesktop.api<{ enabled: boolean; name: string; ok: boolean }>({ ...profileScoped(), path: `/api/webhooks/${encodeURIComponent(name)}/enabled`, method: 'PUT', body: { enabled } }) } // Cron jobs are stored per-profile (/cron/jobs.json), and the // backend's list endpoint defaults to 'all'. Pass a concrete profile key to // list just that profile's jobs, or 'all' for the unified cross-profile view. // Omitting the arg keeps the legacy 'all' default for non-profile callers. // profileScoped() still rides along for backend-process routing. export function getCronJobs(profile?: string): Promise { const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' return window.hermesDesktop.api({ ...profileScoped(), path: `/api/cron/jobs${suffix}`, timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export function getCronJob(jobId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}` }) } export async function getCronJobRuns(jobId: string, limit = 20): Promise { const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({ ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` }) return runs ?? [] } // The single source of truth for cron delivery targets (local + configured // gateways). Both the manual cron editor and the blueprint dialog use this so // they never offer a platform that isn't connected. Mirrors the dashboard. export async function getCronDeliveryTargets(): Promise { const { targets } = await window.hermesDesktop.api<{ targets: CronDeliveryTarget[] }>({ ...profileScoped(), path: '/api/cron/delivery-targets' }) return targets ?? [] } export function createCronJob(body: CronJobCreatePayload): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/cron/jobs', method: 'POST', body }) } export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, method: 'PUT', body: { updates } }) } export function pauseCronJob(jobId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, method: 'POST' }) } export function resumeCronJob(jobId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, method: 'POST' }) } export function triggerCronJob(jobId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, method: 'POST' }) } export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, method: 'DELETE' }) } // Automation Blueprints — parameterized cron templates the backend serves from // cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery // (deliver options already rewritten to this machine's configured gateways); // instantiateAutomationBlueprint fills the slots and creates a real cron job via // the same create_job path as createCronJob. // // Profile-scoping is intentionally asymmetric: the GET catalog is global (the // list endpoint takes no profile — only deliver options are rewritten from the // configured gateways), so it carries only the profileScoped() header for // routing. instantiate creates a real per-profile job, so it names the target // profile explicitly via ?profile=. This mirrors the dashboard's api.ts. export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> { return window.hermesDesktop.api<{ blueprints: AutomationBlueprint[] }>({ ...profileScoped(), path: '/api/cron/blueprints', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export function instantiateAutomationBlueprint( body: { blueprint: string; values: Record }, profile: string ): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, method: 'POST', body }) } export function getProfiles(): Promise { return window.hermesDesktop.api({ path: '/api/profiles', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> { return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({ path: '/api/profiles', method: 'POST', body }) } export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> { return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({ path: `/api/profiles/${encodeURIComponent(name)}`, method: 'PATCH', body: { new_name: newName } }) } export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> { return window.hermesDesktop.api<{ ok: boolean; path: string }>({ path: `/api/profiles/${encodeURIComponent(name)}`, method: 'DELETE' }) } export function getProfileSoul(name: string): Promise { return window.hermesDesktop.api({ path: `/api/profiles/${encodeURIComponent(name)}/soul` }) } export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ path: `/api/profiles/${encodeURIComponent(name)}/soul`, method: 'PUT', body: { content } }) } export function getProfileSetupCommand(name: string): Promise { return window.hermesDesktop.api({ path: `/api/profiles/${encodeURIComponent(name)}/setup-command` }) } /** Export a profile to a shareable .tar.gz on the backend's filesystem. * `extraFiles` stages extra root-level files (desktop.json — the appearance/ * interface overlay) into the archive alongside the profile's own artifacts. */ export function exportProfileArchive( name: string, opts: { extraFiles?: Record; output?: string } = {} ): Promise<{ archive: string; ok: boolean }> { return window.hermesDesktop.api<{ archive: string; ok: boolean }>({ path: `/api/profiles/${encodeURIComponent(name)}/export`, method: 'POST', body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' }, timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } /** Import a profile .tar.gz as a new profile. Returns the bundled desktop * appearance overlay too (when the archive carried one) so the caller can * apply theme/layout without another round-trip. */ export function importProfileArchive( archive: string, name?: string ): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> { return window.hermesDesktop.api<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({ path: '/api/profiles/import', method: 'POST', body: { archive, name: name || null }, timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export function getUsageAnalytics(days = 30): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}` }) } export function getGlobalModelOptions(opts?: { refresh?: boolean includeUnconfigured?: boolean explicitOnly?: boolean }): Promise { const params = new URLSearchParams() if (opts?.refresh) { params.set('refresh', '1') } if (opts?.includeUnconfigured) { params.set('include_unconfigured', '1') } if (opts?.explicitOnly !== false) { params.set('explicit_only', '1') } return window.hermesDesktop.api({ ...profileScoped(), path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export interface RecommendedDefaultModel { provider: string model: string /** True/false for Nous (free vs paid tier); null for other providers. */ free_tier: boolean | null } // Recommended default model for a freshly-authenticated provider. Mirrors the // curation `hermes model` does — for Nous it honors the free/paid tier so a // free user gets a free model instead of a paid default. export function getRecommendedDefaultModel(provider: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` }) } export function setGlobalModel( provider: string, model: string ): Promise<{ ok: boolean; provider: string; model: string }> { return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({ ...profileScoped(), path: '/api/model/set', method: 'POST', body: { scope: 'main', provider, model } }) } export function getAuxiliaryModels(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/model/auxiliary' }) } export function getMoaModels(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/model/moa' }) } export function saveMoaModels(body: MoaConfigResponse): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/model/moa', method: 'PUT', body }) } export function setModelAssignment(body: ModelAssignmentRequest): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/model/set', method: 'POST', body }) } export function restartGateway(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/gateway/restart', method: 'POST' }) } export function updateHermes(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/hermes/update', method: 'POST' }) } /** Query the connected backend's own update state. In remote mode this is the * authoritative source for the backend's behind-count + "what's changed", * distinct from the Electron client clone's git state. */ export function checkHermesUpdate(force = false): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/hermes/update/check${force ? '?force=true' : ''}` }) } export function getActionStatus(name: string, lines = 200): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` }) } export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { return window.hermesDesktop.api({ path: '/api/audio/transcribe', method: 'POST', ...profileScoped(), body: { data_url: dataUrl, mime_type: mimeType }, // Transcription blocks until provider STT, file handling, and response // encoding finish. Remote providers and long clips regularly exceed the // default 15s Electron backend timeout. timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl) }) } export function speakText(text: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/audio/speak', method: 'POST', body: { text }, // TTS blocks until provider synthesis, file read, and base64 encoding // finish. Remote providers and large messages regularly exceed the // default 15s Electron backend timeout. timeoutMs: audioSpeakRequestTimeoutMs(text) }) } export function getElevenLabsVoices(): Promise { return window.hermesDesktop.api({ path: '/api/audio/elevenlabs/voices', ...profileScoped() }) } // --------------------------------------------------------------------------- // Skills hub — search / preview / scan / install (parity with `hermes skills` // and the dashboard's Browse-hub tab). Installs spawn background actions whose // logs are tailed via getActionStatus(). // --------------------------------------------------------------------------- const HUB_REQUEST_TIMEOUT_MS = 45_000 export function getSkillHubSources(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/skills/hub/sources', timeoutMs: HUB_REQUEST_TIMEOUT_MS }) } export function searchSkillsHub(query: string, source = 'all', limit = 20): Promise { const params = new URLSearchParams({ q: query, source, limit: String(limit) }) return window.hermesDesktop.api({ ...profileScoped(), path: `/api/skills/hub/search?${params.toString()}`, timeoutMs: HUB_REQUEST_TIMEOUT_MS }) } export function previewSkillHub(identifier: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, timeoutMs: HUB_REQUEST_TIMEOUT_MS }) } export function scanSkillHub(identifier: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`, timeoutMs: HUB_REQUEST_TIMEOUT_MS }) } export function installSkillFromHub(identifier: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/skills/hub/install', method: 'POST', body: { identifier } }) } export function uninstallSkillFromHub(name: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/skills/hub/uninstall', method: 'POST', body: { name } }) } export function updateSkillsFromHub(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/skills/hub/update', method: 'POST', body: {} }) } // --------------------------------------------------------------------------- // MCP servers — structured list / test / enable toggle / catalog (parity with // `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in // config.yaml via saveHermesConfig. // --------------------------------------------------------------------------- export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> { return window.hermesDesktop.api<{ servers: McpServerSummary[] }>({ ...profileScoped(), path: '/api/mcp/servers' }) } export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, method: 'PUT', body: { enabled } }) } export function getMcpCatalog(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/mcp/catalog' }) } export function installMcpCatalogEntry( name: string, env: Record = {} ): Promise<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }> { return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }>({ ...profileScoped(), path: '/api/mcp/catalog/install', method: 'POST', body: { name, env, enable: true }, timeoutMs: 60_000 }) } // --------------------------------------------------------------------------- // Memory data + curator (parity with `hermes memory` / `hermes curator`). // --------------------------------------------------------------------------- export function getMemoryStatus(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/memory' }) } export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> { return window.hermesDesktop.api<{ ok: boolean; deleted: string[] }>({ ...profileScoped(), path: '/api/memory/reset', method: 'POST', body: { target } }) } export function getCuratorStatus(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/curator' }) } export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> { return window.hermesDesktop.api<{ ok: boolean; paused: boolean }>({ ...profileScoped(), path: '/api/curator/paused', method: 'PUT', body: { paused } }) } export function runCurator(): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: '/api/curator/run', method: 'POST', body: {} }) } // --------------------------------------------------------------------------- // Maintenance operations (parity with `hermes doctor` / `hermes security // audit` / `hermes backup` / `hermes debug share` and the dashboard System // page). All except debug share are spawn-based background actions tailed via // getActionStatus(). // --------------------------------------------------------------------------- export function runDoctor(): Promise { return window.hermesDesktop.api({ path: '/api/ops/doctor', method: 'POST', body: {} }) } export function runSecurityAudit(): Promise { return window.hermesDesktop.api({ path: '/api/ops/security-audit', method: 'POST', body: {} }) } export function runBackup(): Promise { return window.hermesDesktop.api({ path: '/api/ops/backup', method: 'POST', body: {} }) } export function runDebugShare(): Promise { return window.hermesDesktop.api({ path: '/api/ops/debug-share', method: 'POST', body: {}, // Synchronous upload of report + logs to the paste service. timeoutMs: 120_000 }) }