From aa20dbe73e57545b2d4cb7d3383f21d5047cb217 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 03:34:49 -0500 Subject: [PATCH] refactor(desktop): split src/hermes.ts into src/api/ domain modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2,248 lines of gateway REST client become twelve modules by domain, with hermes.ts left as a barrel so all 144 importers stay put. The import graph is a star — every domain module imports only ./client, and client imports nothing back — so there are no cycles. The barrel names client's public exports rather than re-exporting it wholesale. Splitting a module forces its private helpers into exports so siblings can reach them, and export * would then republish them: profileScoped, connectionScoped and capabilityScoped were private to hermes.ts and have to stay that way, or a call site can assemble its own request scope and drift from the api layer. --- apps/desktop/src/api/client.ts | 154 ++ apps/desktop/src/api/config.ts | 239 ++++ apps/desktop/src/api/cron.ts | 153 ++ apps/desktop/src/api/mcp.ts | 147 ++ apps/desktop/src/api/messaging.ts | 131 ++ apps/desktop/src/api/models.ts | 129 ++ apps/desktop/src/api/plugins.ts | 128 ++ apps/desktop/src/api/profiles.ts | 89 ++ apps/desktop/src/api/sessions.ts | 465 ++++++ apps/desktop/src/api/skills.ts | 162 +++ apps/desktop/src/api/system.ts | 247 ++++ apps/desktop/src/api/toolsets.ts | 141 ++ apps/desktop/src/hermes.ts | 2176 +---------------------------- 13 files changed, 2214 insertions(+), 2147 deletions(-) create mode 100644 apps/desktop/src/api/client.ts create mode 100644 apps/desktop/src/api/config.ts create mode 100644 apps/desktop/src/api/cron.ts create mode 100644 apps/desktop/src/api/mcp.ts create mode 100644 apps/desktop/src/api/messaging.ts create mode 100644 apps/desktop/src/api/models.ts create mode 100644 apps/desktop/src/api/plugins.ts create mode 100644 apps/desktop/src/api/profiles.ts create mode 100644 apps/desktop/src/api/sessions.ts create mode 100644 apps/desktop/src/api/skills.ts create mode 100644 apps/desktop/src/api/system.ts create mode 100644 apps/desktop/src/api/toolsets.ts diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts new file mode 100644 index 0000000000..e996ab0109 --- /dev/null +++ b/apps/desktop/src/api/client.ts @@ -0,0 +1,154 @@ +import { JsonRpcGatewayClient } from '@hermes/shared' + +import type { HermesApiRequest } from '@/global' + +// 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 +// 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 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 as request scope. Local calls whose +// REST handlers accept profile reuse the primary dashboard via ?profile=; +// unscoped handlers retain a profile backend. Remote overrides still route to +// their owning backend. Null → primary, so single-profile users are unaffected. +let _apiProfile: null | string = null + +export function setApiRequestProfile(profile: null | string): void { + _apiProfile = profile || null +} + +export 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 +} + +// Registry connection serving the active gateway (null → the local pool). +// Pushed from store/gateway's setActive — the single seam BOTH +// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls +// that dial their own backend (pluginSocket) resolve it through the SAME +// source of truth those paths maintain for $connection. That makes the plugin +// socket follow registry-agent activations too, not just profile switches. +// Same no-store-import contract as _apiProfile (avoids a cycle). +let _apiConnectionId: null | string = null + +export function setApiRequestConnection(connectionId: null | string): void { + _apiConnectionId = connectionId || null +} + +// Registry connection scope for a REST request. A registered remote gateway +// owns its own state.db — cron jobs and their run sessions live THERE — so +// requests for gateway-owned data must carry the connection id for the main +// process to route them to that host (hermes:api's registry branch). Null +// resolves to no tag, keeping single-source users byte-identical; explicit +// 'local' must remain tagged when the legacy primary points elsewhere. +export function connectionScoped(): { connectionId?: string } { + return _apiConnectionId ? { connectionId: _apiConnectionId } : {} +} + +/** Send a REST request to the renderer's active registry source. Request-level + * routing may override the active source for an explicitly-owned resource. + * + * Helpers under `api/` go through here rather than calling the preload bridge + * directly, so the connection tag cannot be forgotten on a new one — with one + * exception. A capabilityScoped() helper must NOT: that scope says "the local + * pool" by omitting `connectionId` entirely, and an absent key cannot override + * the ambient tag spread underneath it, so a 'local' pin would silently route + * to whatever remote gateway happened to be active. Those helpers call the + * bridge directly and own their routing end to end. */ +export function hermesApi(request: HermesApiRequest): Promise { + return window.hermesDesktop.api({ ...connectionScoped(), ...request }) +} + +// ── Capability scope: (connection, profile) routing for the Capabilities +// surface (skills / toolsets / MCP / hub / env / toolset config) ──────────── +// +// A profile is not a machine-global name — it belongs to ONE gateway. The +// Capabilities surface can be pointed at any (connection, profile) pair +// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so +// its REST helpers accept either the legacy string form or an explicit scope +// object: +// +// - `undefined` / string → the legacy profile path, PLUS the active registry +// connection tag (connectionScoped, same contract the cron helpers adopted +// in #87882). Without the tag, a window activated onto a registered remote +// gateway read the LOCAL pool's skills/tools/MCP — the wrong machine. +// - `{ connectionId, profile }` → explicit pin. `''`/`'local'` connection +// ids mean the local pool and deliberately DROP the ambient connection +// tag, so a local-profile pick made while a remote gateway is active still +// routes to the local machine. +export type ProfileScope = null | string | { connectionId?: null | string; profile?: null | string } + +export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + if (scope && typeof scope === 'object') { + const profile = (scope.profile ?? '').trim() + const connectionId = (scope.connectionId ?? '').trim() + + return { + ...(profile ? { profile } : {}), + ...(connectionId && connectionId !== 'local' ? { connectionId } : {}) + } + } + + return { ...profileScoped(scope), ...connectionScoped() } +} + +/** Stable cache-key for a capability scope: `profile` for the local/legacy + * path, `connectionId::profile` for an explicit remote pin. Mirrors + * normalizeProfileKey for plain strings so existing keys stay byte-identical. */ +export function profileScopeKey(scope?: ProfileScope): string { + if (scope && typeof scope === 'object') { + const profile = (scope.profile ?? '').trim() || 'default' + const connectionId = (scope.connectionId ?? '').trim() + + return connectionId && connectionId !== 'local' ? `${connectionId}::${profile}` : profile + } + + return (scope ?? '').trim() || 'default' +} + +/** Registry connection id that connection-scoped WS calls should target + * (null → the local pool). Read-only twin of setApiRequestConnection. */ +export function getApiRequestConnection(): null | string { + return _apiConnectionId +} diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts new file mode 100644 index 0000000000..38fd4945d6 --- /dev/null +++ b/apps/desktop/src/api/config.ts @@ -0,0 +1,239 @@ +import type { + ConfigSchemaResponse, + CustomEndpointsResponse, + CustomEndpointUpdate, + CustomEndpointValidationResponse, + EnvVarInfo, + HermesConfig, + HermesConfigRecord, + LogsResponse, + OAuthPollResponse, + OAuthProvidersResponse, + OAuthStartResponse, + OAuthSubmitResponse, + StatusResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getStatus(): Promise { + return hermesApi({ + ...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 hermesApi({ + ...profileScoped(), + path: suffix ? `/api/logs?${suffix}` : '/api/logs' + }) +} + +export function getHermesConfig(profile?: string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/config', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getHermesConfigRecord(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/config' + }) +} + +export function getHermesConfigDefaults(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/config/defaults', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getHermesConfigSchema(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/config/schema' + }) +} + +export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: '/api/config', + method: 'PUT', + body: { config } + }) +} + +export function getEnvVars(profile?: null | string): Promise> { + return hermesApi>({ + ...profileScoped(profile), + path: '/api/env' + }) +} + +export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/env', + method: 'PUT', + body: { key, value } + }) +} + +export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/env', + method: 'DELETE', + body: { key } + }) +} + +export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> { + return window.hermesDesktop.api<{ key: string; value: string }>({ + ...capabilityScoped(profile), + path: '/api/env/reveal', + method: 'POST', + body: { key } + }) +} + +export function validateProviderCredential( + key: string, + value: string, + apiKey?: string +): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> { + return hermesApi<{ 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 hermesApi({ + ...profileScoped(), + path: '/api/providers/custom-endpoints' + }) +} + +export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/custom-endpoints', + method: 'POST', + body: endpoint + }) +} + +export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return hermesApi({ + path: '/api/providers/custom-endpoints/validate', + method: 'POST', + body: endpoint + }) +} + +export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> { + return hermesApi<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, + method: 'POST' + }) +} + +export function deleteCustomEndpoint(id: string): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, + method: 'DELETE' + }) +} + +export function listOAuthProviders(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/oauth' + }) +} + +export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> { + return hermesApi<{ ok: boolean; provider: string }>({ + ...profileScoped(), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, + method: 'DELETE' + }) +} + +export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, + method: 'POST', + body: {} + }) +} + +export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, + method: 'POST', + body: { session_id: sessionId, code } + }) +} + +export function pollOAuthSession( + providerId: string, + sessionId: string, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` + }) +} + +export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, + method: 'DELETE' + }) +} diff --git a/apps/desktop/src/api/cron.ts b/apps/desktop/src/api/cron.ts new file mode 100644 index 0000000000..b790b033d7 --- /dev/null +++ b/apps/desktop/src/api/cron.ts @@ -0,0 +1,153 @@ +import type { + AutomationBlueprint, + CronDeliveryTarget, + CronJob, + CronJobCreatePayload, + CronJobUpdates, + SessionInfo +} from '@/types/hermes' + +import { connectionScoped, hermesApi, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +// The cron trigger endpoint intentionally waits for the whole job so its +// response reflects the persisted execution result. Agent jobs can run far +// longer than the Electron fetch default; keep this override local to the one +// synchronous long-operation endpoint rather than weakening all API timeouts. +const CRON_TRIGGER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000 + +// 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 hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs${suffix}`, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}` + }) +} + +export async function getCronJobRuns(jobId: string, limit = 20): Promise { + const { runs } = await hermesApi<{ runs: SessionInfo[] }>({ + ...profileScoped(), + ...connectionScoped(), + 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 hermesApi<{ targets: CronDeliveryTarget[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/delivery-targets' + }) + + return targets ?? [] +} + +export function createCronJob(body: CronJobCreatePayload): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/jobs', + method: 'POST', + body + }) +} + +export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'PUT', + body: { updates } + }) +} + +export function pauseCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, + method: 'POST' + }) +} + +export function resumeCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, + method: 'POST' + }) +} + +export function triggerCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, + method: 'POST', + timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS + }) +} + +export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + ...connectionScoped(), + 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 hermesApi<{ blueprints: AutomationBlueprint[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/blueprints', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function instantiateAutomationBlueprint( + body: { blueprint: string; values: Record }, + profile: string +): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, + method: 'POST', + body + }) +} diff --git a/apps/desktop/src/api/mcp.ts b/apps/desktop/src/api/mcp.ts new file mode 100644 index 0000000000..f67870e5bf --- /dev/null +++ b/apps/desktop/src/api/mcp.ts @@ -0,0 +1,147 @@ +import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export interface McpTestResult { + ok: boolean + error?: string + /** `schema_chars` (converted registry-schema size, chars) is additive — + * older backends omit it and the cost overlay shows no token estimate. */ + tools: { name: string; description: string; schema_chars?: number }[] + /** 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, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + 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>, + profile?: ProfileScope +): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/mcp/servers', + method: 'PUT', + body: { servers } + }) +} + +/** Start an MCP OAuth flow and return the authorization URL. */ +export function authMcpServer(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`, + method: 'POST', + timeoutMs: 60_000 + }) +} + +export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}` + }) +} + +/** Cancel an in-flight MCP OAuth flow server-side, freeing the per-server + * "already in progress" slot so a retry doesn't 409. */ +export function cancelMcpOAuthFlow(flowId: string, profile?: null | string): Promise<{ ok: boolean; status: string }> { + return hermesApi<{ ok: boolean; status: string }>({ + ...profileScoped(profile), + path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`, + method: 'DELETE' + }) +} + +// --------------------------------------------------------------------------- +// 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 hermesApi<{ servers: McpServerSummary[] }>({ + ...profileScoped(), + path: '/api/mcp/servers' + }) +} + +/** Add one server to `mcp_servers` (validated + name-collision-checked + * server-side — the same endpoint the dashboard's add form uses). */ +export function addMcpServer(body: { + name: string + url?: string + command?: string + args?: string[] + env?: Record + auth?: string +}): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/mcp/servers', + method: 'POST', + body + }) +} + +/** Remove one server from `mcp_servers` (the inline setup card's rollback + * when a directory install is cancelled after the config write). */ +export function removeMcpServer(name: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/mcp/servers/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} + +export function getMcpCatalog(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/mcp/catalog' + }) +} + +export function installMcpCatalogEntry( + name: string, + env: Record = {}, + profile?: ProfileScope +): 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 }>({ + ...capabilityScoped(profile), + path: '/api/mcp/catalog/install', + method: 'POST', + body: { name, env, enable: true }, + timeoutMs: 60_000 + }) +} diff --git a/apps/desktop/src/api/messaging.ts b/apps/desktop/src/api/messaging.ts new file mode 100644 index 0000000000..b49bdc93b8 --- /dev/null +++ b/apps/desktop/src/api/messaging.ts @@ -0,0 +1,131 @@ +import type { + MessagingPlatformsResponse, + MessagingPlatformTestResponse, + MessagingPlatformUpdate, + PairingResponse, + PairingUser, + WebhookCreatePayload, + WebhookCreateResponse, + WebhookEnableResponse, + WebhooksResponse +} from '@/types/hermes' + +import { hermesApi, profileScoped } from './client' + +export function getMessagingPlatforms(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/messaging/platforms' + }) +} + +export function updateMessagingPlatform( + platformId: string, + body: MessagingPlatformUpdate, + profile?: null | string +): Promise<{ ok: boolean; platform: string }> { + return hermesApi<{ ok: boolean; platform: string }>({ + ...profileScoped(profile), + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`, + method: 'PUT', + body + }) +} + +export function testMessagingPlatform( + platformId: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + 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(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/pairing' + }) +} + +export function approvePairing( + platform: string, + requestId: string, + profile?: null | string +): Promise<{ ok: boolean; user: PairingUser }> { + return hermesApi<{ ok: boolean; user: PairingUser }>({ + ...profileScoped(profile), + 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(profile) } + }) +} + +export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: '/api/pairing/revoke', + method: 'POST', + body: { platform, user_id: userId, ...profileScoped(profile) } + }) +} + +// -- 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 hermesApi({ + ...profileScoped(), + path: '/api/webhooks' + }) +} + +export function enableWebhooks(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks/enable', + method: 'POST' + }) +} + +export function createWebhook(body: WebhookCreatePayload): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks', + method: 'POST', + body + }) +} + +export function deleteWebhook(name: string): Promise<{ ok: boolean }> { + return hermesApi<{ 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 hermesApi<{ enabled: boolean; name: string; ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts new file mode 100644 index 0000000000..0aa1ca951f --- /dev/null +++ b/apps/desktop/src/api/models.ts @@ -0,0 +1,129 @@ +import type { + AnalyticsResponse, + AuxiliaryModelsResponse, + MoaConfigResponse, + ModelAssignmentRequest, + ModelAssignmentResponse, + ModelInfoResponse, + ModelOptionsResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getGlobalModelInfo(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/info', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}` + }) +} + +export function getGlobalModelOptions( + opts?: { + refresh?: boolean + includeUnconfigured?: boolean + explicitOnly?: boolean + }, + profile?: null | string +): 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 hermesApi({ + ...profileScoped(profile), + 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, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` + }) +} + +export function setGlobalModel( + provider: string, + model: string +): Promise<{ ok: boolean; provider: string; model: string }> { + return hermesApi<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), + path: '/api/model/set', + method: 'POST', + body: { + scope: 'main', + provider, + model + } + }) +} + +export function getAuxiliaryModels(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/auxiliary' + }) +} + +export function getMoaModels(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/moa' + }) +} + +export function saveMoaModels( + body: MoaConfigResponse, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/moa', + method: 'PUT', + body + }) +} + +export function setModelAssignment( + body: ModelAssignmentRequest, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/set', + method: 'POST', + body + }) +} diff --git a/apps/desktop/src/api/plugins.ts b/apps/desktop/src/api/plugins.ts new file mode 100644 index 0000000000..ff8e4a6b0c --- /dev/null +++ b/apps/desktop/src/api/plugins.ts @@ -0,0 +1,128 @@ +import type { HermesConnection } from '@/global' +import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' + +import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client' + +/** Resolve the ACTIVE backend's connection descriptor, (connectionId, + * profile)-scoped — mirroring how store/profile resolves $connection: a + * registry agent's descriptor comes from getConnectionFor (its SOURCE + * connection), everything else from the profile-keyed local pool. The + * getConnectionFor bridge is optional (older Desktop mains); without it the + * profile-scoped pool lookup is the best available answer. */ +async function activeConnection(): Promise { + const getConnectionFor = window.hermesDesktop.getConnectionFor + const connectionId = getApiRequestConnection() + + if (connectionId && getConnectionFor) { + return getConnectionFor({ connectionId, profile: getApiRequestProfile() }) + } + + return window.hermesDesktop.getConnection(getApiRequestProfile()) +} + +/** 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 hermesApi({ + 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 activeConnection().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() + } +} diff --git a/apps/desktop/src/api/profiles.ts b/apps/desktop/src/api/profiles.ts new file mode 100644 index 0000000000..52fc090906 --- /dev/null +++ b/apps/desktop/src/api/profiles.ts @@ -0,0 +1,89 @@ +import type { + ProfileCreatePayload, + ProfileDesktopOverlay, + ProfileSetupCommand, + ProfileSoul, + ProfilesResponse +} from '@/types/hermes' + +import { hermesApi, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getProfiles(): Promise { + return hermesApi({ + path: '/api/profiles', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> { + return hermesApi<{ 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 hermesApi<{ 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 hermesApi<{ ok: boolean; path: string }>({ + path: `/api/profiles/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function getProfileSoul(name: string): Promise { + return hermesApi({ + path: `/api/profiles/${encodeURIComponent(name)}/soul` + }) +} + +export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + path: `/api/profiles/${encodeURIComponent(name)}/soul`, + method: 'PUT', + body: { content } + }) +} + +export function getProfileSetupCommand(name: string): Promise { + return hermesApi({ + 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 hermesApi<{ 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 hermesApi<{ 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 + }) +} diff --git a/apps/desktop/src/api/sessions.ts b/apps/desktop/src/api/sessions.ts new file mode 100644 index 0000000000..e8f17ac226 --- /dev/null +++ b/apps/desktop/src/api/sessions.ts @@ -0,0 +1,465 @@ +import { isMissingRestEndpoint } from '@/lib/gateway-rpc' +import { recordTranscriptTail } from '@/store/transcript-tail' +import type { + PaginatedSessions, + SessionInfo, + SessionMessage, + SessionMessagesResponse, + SessionSearchResponse +} from '@/types/hermes' + +import { hermesApi } from './client' + +const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 + +/** + * 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 hermesApi({ + 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 hermesApi({ + 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 + /** 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 +} + +/** 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 +} + +// 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 { + 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 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 } : {}) + } +} + +/** 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; scanned: string[] }> { + return hermesApi<{ + pull_requests: Record + scanned: string[] + }>({ + path: '/api/profiles/sessions/pull-requests', + method: 'POST', + body: { ids } + }) +} + +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 hermesApi({ + 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: 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 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 }> { + return hermesApi<{ 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 hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { pinned } + }) +} + +// 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 }> { + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { unread } + }) +} + +export function searchSessions(query: string): Promise { + return hermesApi({ + 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 hermesApi({ + ...(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'; includeCompacted?: boolean } = {} +): 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) + } + + if (page.includeCompacted !== undefined) { + query.set('include_compacted', String(page.includeCompacted)) + } + + const suffix = query.size ? `?${query.toString()}` : '' + + return hermesApi({ + ...(profile ? { profile } : {}), + 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?: string | null): Promise { + // 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 + }) +} + +/** + * 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: string | null | undefined, + offset: number, + limit: number = LATEST_SESSION_MESSAGES_LIMIT +): Promise { + return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, 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', + 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?: string | null): Promise<{ ok: boolean }> { + return hermesApi<{ 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 hermesApi<{ ok: boolean; title: string }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { title, ...(profile ? { profile } : {}) } + }) +} diff --git a/apps/desktop/src/api/skills.ts b/apps/desktop/src/api/skills.ts new file mode 100644 index 0000000000..cf29d2daf2 --- /dev/null +++ b/apps/desktop/src/api/skills.ts @@ -0,0 +1,162 @@ +import type { + SkillHubPreview, + SkillHubScanResult, + SkillHubSearchResponse, + SkillHubSourcesResponse, + SkillInfo, + StarmapGraph +} from '@/types/hermes' +import type { ActionResponse } from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export function getSkills(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills' + }) +} + +/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or + * learned — backing the Capabilities detail pane's full-skill view. */ +export function getSkillContent( + name: string, + profile?: ProfileScope +): Promise<{ content: string; name: string; path: string }> { + return window.hermesDesktop.api<{ content: string; name: string; path: string }>({ + ...capabilityScoped(profile), + path: `/api/skills/content?name=${encodeURIComponent(name)}` + }) +} + +export function setSkillEnabled( + name: string, + enabled: boolean, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...capabilityScoped(profile), + path: '/api/skills/toggle', + method: 'PUT', + body: { name, enabled } + }) +} + +export function getStarmapGraph(): Promise { + return hermesApi({ + ...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, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/learning/node?id=${encodeURIComponent(id)}` + }) +} + +export function deleteLearningNode(id: string, profile?: ProfileScope): Promise<{ message: string; ok: boolean }> { + return window.hermesDesktop.api<{ message: string; ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/learning/node', + method: 'DELETE', + body: { id } + }) +} + +export function editLearningNode( + id: string, + content: string, + profile?: ProfileScope +): Promise<{ message: string; ok: boolean }> { + return window.hermesDesktop.api<{ message: string; ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/learning/node', + method: 'PUT', + body: { content, id } + }) +} + +// --------------------------------------------------------------------------- +// 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(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/skills/hub/sources', + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function searchSkillsHub( + query: string, + source = 'all', + limit = 20, + profile?: null | string +): Promise { + const params = new URLSearchParams({ q: query, source, limit: String(limit) }) + + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/search?${params.toString()}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function previewSkillHub(identifier: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function scanSkillHub(identifier: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/install', + method: 'POST', + body: { identifier } + }) +} + +export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/uninstall', + method: 'POST', + body: { name } + }) +} + +export function updateSkillsFromHub(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/update', + method: 'POST', + body: {} + }) +} diff --git a/apps/desktop/src/api/system.ts b/apps/desktop/src/api/system.ts new file mode 100644 index 0000000000..678d09e549 --- /dev/null +++ b/apps/desktop/src/api/system.ts @@ -0,0 +1,247 @@ +import type { + ActionResponse, + ActionStatusResponse, + AudioSpeakResponse, + AudioTranscriptionResponse, + BackendUpdateCheckResponse, + CuratorStatusResponse, + DebugShareResponse, + ElevenLabsVoicesResponse, + MemoryProviderConfig, + MemoryProviderOAuthStatus, + MemoryStatusResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +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) +} + +// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. +export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` + }) +} + +export function saveMemoryProviderConfig( + provider: string, + values: Record, + profile?: null | string +): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, + method: 'PUT', + body: { values } + }) +} + +// 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, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`, + method: 'POST' + }) +} + +export function getMemoryProviderOAuthStatus( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status` + }) +} + +// --------------------------------------------------------------------------- +// Memory data + curator (parity with `hermes memory` / `hermes curator`). +// --------------------------------------------------------------------------- + +export function getMemoryStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/memory' + }) +} + +export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> { + return hermesApi<{ ok: boolean; deleted: string[] }>({ + ...profileScoped(), + path: '/api/memory/reset', + method: 'POST', + body: { target } + }) +} + +export function getCuratorStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/curator' + }) +} + +export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> { + return hermesApi<{ ok: boolean; paused: boolean }>({ + ...profileScoped(), + path: '/api/curator/paused', + method: 'PUT', + body: { paused } + }) +} + +export function runCurator(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/curator/run', + method: 'POST', + body: {} + }) +} + +export function restartGateway(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/gateway/restart', + method: 'POST' + }) +} + +export function updateHermes(): Promise { + return hermesApi({ + ...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 hermesApi({ + ...profileScoped(), + path: `/api/hermes/update/check${force ? '?force=true' : ''}` + }) +} + +export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` + }) +} + +export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { + return hermesApi({ + 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 hermesApi({ + ...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(profile?: null | string): Promise { + return hermesApi({ + path: '/api/audio/elevenlabs/voices', + ...profileScoped(profile) + }) +} + +/** `gh` CLI presence + auth state, for the composer's GitHub skill pill + * (GitHub is deliberately not an MCP — the github/* skills are the + * integration). Backend caches for 5 minutes; `refresh` bypasses. */ +export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> { + return hermesApi<{ available: boolean; authenticated: boolean }>({ + ...profileScoped(), + path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}` + }) +} + +// --------------------------------------------------------------------------- +// 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 hermesApi({ path: '/api/ops/doctor', method: 'POST', body: {} }) +} + +export function runSecurityAudit(): Promise { + return hermesApi({ path: '/api/ops/security-audit', method: 'POST', body: {} }) +} + +export function runBackup(): Promise { + return hermesApi({ + path: '/api/ops/backup', + method: 'POST', + body: {} + }) +} + +export function runDebugShare(): Promise { + return hermesApi({ + path: '/api/ops/debug-share', + method: 'POST', + body: {}, + // Synchronous upload of report + logs to the paste service. + timeoutMs: 120_000 + }) +} diff --git a/apps/desktop/src/api/toolsets.ts b/apps/desktop/src/api/toolsets.ts new file mode 100644 index 0000000000..862c0fc521 --- /dev/null +++ b/apps/desktop/src/api/toolsets.ts @@ -0,0 +1,141 @@ +import type { + ActionResponse, + ComputerUseStatus, + TerminalBackendsResponse, + ToolsetConfig, + ToolsetInfo, + ToolsetModelsResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +// The optional trailing `profile` on every capability fetcher below is the +// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP +// panels configure ANY profile without swapping the app-wide active profile. +// Omitting it (every pre-existing caller) means `profileScoped(undefined)` +// falls back to the app-wide `_apiProfile`, so behavior is byte-identical. +export function getToolsets(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/tools/toolsets' + }) +} + +export function setToolsetEnabled( + name: string, + enabled: boolean, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}`, + method: 'PUT', + body: { enabled } + }) +} + +export function getToolsetConfig(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/config` + }) +} + +export function getToolsetModels( + name: string, + provider?: string, + profile?: ProfileScope +): Promise { + const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : '' + + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}` + }) +} + +export function selectToolsetModel( + name: string, + model: string, + provider?: string, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; model: string }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({ + ...capabilityScoped(profile), + 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', + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, + method: 'PUT', + body: capability ? { provider, capability } : { provider } + }) +} + +export function runToolsetPostSetup( + name: string, + key: string, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, + method: 'POST', + body: { key } + }) +} + +export function getTerminalBackends(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/terminal/backends' + }) +} + +export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> { + return hermesApi<{ ok: boolean; backend: string }>({ + ...profileScoped(), + path: '/api/tools/terminal/backend', + method: 'PUT', + body: { backend } + }) +} + +export function getComputerUseStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/computer-use/status' + }) +} + +export function grantComputerUsePermissions(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/computer-use/permissions/grant', + method: 'POST' + }) +} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index aab8cb1ab2..6242ef5998 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -1,137 +1,32 @@ -import { JsonRpcGatewayClient } from '@hermes/shared' - -import type { HermesApiRequest, HermesConnection } from '@/global' -import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' -import { recordTranscriptTail } from '@/store/transcript-tail' -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 -// The cron trigger endpoint intentionally waits for the whole job so its -// response reflects the persisted execution result. Agent jobs can run far -// longer than the Electron fetch default; keep this override local to the one -// synchronous long-operation endpoint rather than weakening all API timeouts. -export const CRON_TRIGGER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000 -// 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) -} +// The desktop REST/WS client, split by domain under src/api/. This module is +// the compatibility barrel: every helper keeps its historical `@/hermes` +// import path while the implementations live in focused files. +// client is the one module with internals: profileScoped / connectionScoped / +// capabilityScoped are shared across api/ but must not reach call sites, or +// request scoping stops having a single owner. +export { + getApiRequestConnection, + getApiRequestProfile, + hermesApi, + HermesGateway, + profileScopeKey, + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, + setApiRequestConnection, + setApiRequestProfile, + STARTUP_REQUEST_TIMEOUT_MS +} from './api/client' +export type { ProfileScope } from './api/client' +export * from './api/config' +export * from './api/cron' +export * from './api/mcp' +export * from './api/messaging' +export * from './api/models' +export * from './api/plugins' +export * from './api/profiles' +export * from './api/sessions' +export * from './api/skills' +export * from './api/system' +export * from './api/toolsets' export type { ActionResponse, @@ -233,2016 +128,3 @@ export type { 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 as request scope. Local calls whose -// REST handlers accept profile reuse the primary dashboard via ?profile=; -// unscoped handlers retain a profile backend. Remote overrides still route to -// their owning backend. 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 -} - -// Registry connection serving the active gateway (null → the local pool). -// Pushed from store/gateway's setActive — the single seam BOTH -// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls -// that dial their own backend (pluginSocket) resolve it through the SAME -// source of truth those paths maintain for $connection. That makes the plugin -// socket follow registry-agent activations too, not just profile switches. -// Same no-store-import contract as _apiProfile (avoids a cycle). -let _apiConnectionId: null | string = null - -export function setApiRequestConnection(connectionId: null | string): void { - _apiConnectionId = connectionId || null -} - -// Registry connection scope for a REST request. A registered remote gateway -// owns its own state.db — cron jobs and their run sessions live THERE — so -// requests for gateway-owned data must carry the connection id for the main -// process to route them to that host (hermes:api's registry branch). Null -// resolves to no tag, keeping single-source users byte-identical; explicit -// 'local' must remain tagged when the legacy primary points elsewhere. -function connectionScoped(): { connectionId?: string } { - return _apiConnectionId ? { connectionId: _apiConnectionId } : {} -} - -// ── Capability scope: (connection, profile) routing for the Capabilities -// surface (skills / toolsets / MCP / hub / env / toolset config) ──────────── -// -// A profile is not a machine-global name — it belongs to ONE gateway. The -// Capabilities surface can be pointed at any (connection, profile) pair -// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so -// its REST helpers accept either the legacy string form or an explicit scope -// object: -// -// - `undefined` / string → the legacy profile path, PLUS the active registry -// connection tag (connectionScoped, same contract the cron helpers adopted -// in #87882). Without the tag, a window activated onto a registered remote -// gateway read the LOCAL pool's skills/tools/MCP — the wrong machine. -// - `{ connectionId, profile }` → explicit pin. `''`/`'local'` connection -// ids mean the local pool and deliberately DROP the ambient connection -// tag, so a local-profile pick made while a remote gateway is active still -// routes to the local machine. -export type ProfileScope = null | string | { connectionId?: null | string; profile?: null | string } - -function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { - if (scope && typeof scope === 'object') { - const profile = (scope.profile ?? '').trim() - const connectionId = (scope.connectionId ?? '').trim() - - return { - ...(profile ? { profile } : {}), - ...(connectionId && connectionId !== 'local' ? { connectionId } : {}) - } - } - - return { ...profileScoped(scope), ...connectionScoped() } -} - -/** Stable cache-key for a capability scope: `profile` for the local/legacy - * path, `connectionId::profile` for an explicit remote pin. Mirrors - * normalizeProfileKey for plain strings so existing keys stay byte-identical. */ -export function profileScopeKey(scope?: ProfileScope): string { - if (scope && typeof scope === 'object') { - const profile = (scope.profile ?? '').trim() || 'default' - const connectionId = (scope.connectionId ?? '').trim() - - return connectionId && connectionId !== 'local' ? `${connectionId}::${profile}` : profile - } - - return (scope ?? '').trim() || 'default' -} - -/** Send a REST request to the renderer's active registry source. Request-level - * routing may override the active source for an explicitly-owned resource. */ -export function hermesApi(request: HermesApiRequest): Promise { - return window.hermesDesktop.api({ ...connectionScoped(), ...request }) -} - -/** Registry connection id that connection-scoped WS calls should target - * (null → the local pool). Read-only twin of setApiRequestConnection. */ -export function getApiRequestConnection(): null | string { - return _apiConnectionId -} - -/** Resolve the ACTIVE backend's connection descriptor, (connectionId, - * profile)-scoped — mirroring how store/profile resolves $connection: a - * registry agent's descriptor comes from getConnectionFor (its SOURCE - * connection), everything else from the profile-keyed local pool. The - * getConnectionFor bridge is optional (older Desktop mains); without it the - * profile-scoped pool lookup is the best available answer. */ -async function activeConnection(): Promise { - const getConnectionFor = window.hermesDesktop.getConnectionFor - - if (_apiConnectionId && getConnectionFor) { - return getConnectionFor({ connectionId: _apiConnectionId, profile: _apiProfile }) - } - - return window.hermesDesktop.getConnection(_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 hermesApi({ - 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 activeConnection().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 hermesApi({ - 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 hermesApi({ - 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 - /** 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 -} - -/** 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; 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 { - 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 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 } : {}) - } -} - -/** 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; scanned: string[] }> { - return hermesApi<{ - pull_requests: Record - scanned: string[] - }>({ - path: '/api/profiles/sessions/pull-requests', - method: 'POST', - body: { ids } - }) -} - -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 hermesApi({ - 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 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 }> { - return hermesApi<{ 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 hermesApi<{ ok: boolean }>({ - ...(profile ? { profile } : {}), - path: `/api/sessions/${encodeURIComponent(id)}`, - method: 'PATCH', - body: { pinned } - }) -} - -// 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 }> { - return hermesApi<{ ok: boolean }>({ - ...(profile ? { profile } : {}), - path: `/api/sessions/${encodeURIComponent(id)}`, - method: 'PATCH', - body: { unread } - }) -} - -export function searchSessions(query: string): Promise { - return hermesApi({ - 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 hermesApi({ - ...(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'; includeCompacted?: boolean } = {} -): 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) - } - - if (page.includeCompacted !== undefined) { - query.set('include_compacted', String(page.includeCompacted)) - } - - const suffix = query.size ? `?${query.toString()}` : '' - - return hermesApi({ - ...(profile ? { profile } : {}), - 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?: string | null): Promise { - // 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 - }) -} - -/** - * 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: string | null | undefined, - offset: number, - limit: number = LATEST_SESSION_MESSAGES_LIMIT -): Promise { - return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, 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', - 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?: string | null): Promise<{ ok: boolean }> { - return hermesApi<{ 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 hermesApi<{ ok: boolean; title: string }>({ - ...(profile ? { profile } : {}), - path: `/api/sessions/${encodeURIComponent(id)}`, - method: 'PATCH', - body: { title, ...(profile ? { profile } : {}) } - }) -} - -export function getGlobalModelInfo(profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/model/info', - timeoutMs: STARTUP_REQUEST_TIMEOUT_MS - }) -} - -export function getStatus(): Promise { - return hermesApi({ - ...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 hermesApi({ - ...profileScoped(), - path: suffix ? `/api/logs?${suffix}` : '/api/logs' - }) -} - -export function getHermesConfig(profile?: string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/config', - timeoutMs: STARTUP_REQUEST_TIMEOUT_MS - }) -} - -export function getHermesConfigRecord(profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: '/api/config' - }) -} - -export function getHermesConfigDefaults(): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/config/defaults', - timeoutMs: STARTUP_REQUEST_TIMEOUT_MS - }) -} - -export function getHermesConfigSchema(profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/config/schema' - }) -} - -export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> { - return hermesApi<{ ok: boolean }>({ - ...profileScoped(profile), - 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, profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` - }) -} - -export function saveMemoryProviderConfig( - provider: string, - values: Record, - profile?: null | string -): Promise<{ ok: boolean }> { - return hermesApi<{ ok: boolean }>({ - ...profileScoped(profile), - path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, - method: 'PUT', - body: { values } - }) -} - -export function getEnvVars(profile?: null | string): Promise> { - return hermesApi>({ - ...profileScoped(profile), - path: '/api/env' - }) -} - -export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ - ...capabilityScoped(profile), - 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 hermesApi<{ 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 hermesApi({ - ...profileScoped(), - path: '/api/providers/custom-endpoints' - }) -} - -export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/providers/custom-endpoints', - method: 'POST', - body: endpoint - }) -} - -export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { - return hermesApi({ - path: '/api/providers/custom-endpoints/validate', - method: 'POST', - body: endpoint - }) -} - -export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> { - return hermesApi<{ ok: boolean; provider: string; model: string }>({ - ...profileScoped(), - path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, - method: 'POST' - }) -} - -export function deleteCustomEndpoint(id: string): Promise { - return hermesApi({ - ...profileScoped(), - path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, - method: 'DELETE' - }) -} - -export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ - ...capabilityScoped(profile), - path: '/api/env', - method: 'DELETE', - body: { key } - }) -} - -export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> { - return window.hermesDesktop.api<{ key: string; value: string }>({ - ...capabilityScoped(profile), - path: '/api/env/reveal', - method: 'POST', - body: { key } - }) -} - -export function listOAuthProviders(): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/providers/oauth' - }) -} - -export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> { - return hermesApi<{ ok: boolean; provider: string }>({ - ...profileScoped(), - path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, - method: 'DELETE' - }) -} - -export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, - method: 'POST', - body: {} - }) -} - -export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise { - return hermesApi({ - ...profileScoped(), - path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, - method: 'POST', - body: { session_id: sessionId, code } - }) -} - -export function pollOAuthSession( - providerId: string, - sessionId: string, - profile?: ProfileScope -): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` - }) -} - -export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { - return hermesApi<{ 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, - profile?: null | string -): Promise { - return hermesApi({ - ...profileScoped(profile), - path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`, - method: 'POST' - }) -} - -export function getMemoryProviderOAuthStatus( - provider: string, - profile?: null | string -): Promise { - return hermesApi({ - ...profileScoped(profile), - path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status` - }) -} - -export function getSkills(profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: '/api/skills' - }) -} - -/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or - * learned — backing the Capabilities detail pane's full-skill view. */ -export function getSkillContent( - name: string, - profile?: ProfileScope -): Promise<{ content: string; name: string; path: string }> { - return window.hermesDesktop.api<{ content: string; name: string; path: string }>({ - ...capabilityScoped(profile), - path: `/api/skills/content?name=${encodeURIComponent(name)}` - }) -} - -export function getStarmapGraph(): Promise { - return hermesApi({ - ...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, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/learning/node?id=${encodeURIComponent(id)}` - }) -} - -export function deleteLearningNode(id: string, profile?: ProfileScope): Promise<{ message: string; ok: boolean }> { - return window.hermesDesktop.api<{ message: string; ok: boolean }>({ - ...capabilityScoped(profile), - path: '/api/learning/node', - method: 'DELETE', - body: { id } - }) -} - -export function editLearningNode( - id: string, - content: string, - profile?: ProfileScope -): Promise<{ message: string; ok: boolean }> { - return window.hermesDesktop.api<{ message: string; ok: boolean }>({ - ...capabilityScoped(profile), - path: '/api/learning/node', - method: 'PUT', - body: { content, id } - }) -} - -export function setSkillEnabled( - name: string, - enabled: boolean, - profile?: ProfileScope -): Promise<{ ok: boolean; name: string; enabled: boolean }> { - return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ - ...capabilityScoped(profile), - path: '/api/skills/toggle', - method: 'PUT', - body: { name, enabled } - }) -} - -export interface McpTestResult { - ok: boolean - error?: string - /** `schema_chars` (converted registry-schema size, chars) is additive — - * older backends omit it and the cost overlay shows no token estimate. */ - tools: { name: string; description: string; schema_chars?: number }[] - /** 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, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - 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>, - profile?: ProfileScope -): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ - ...capabilityScoped(profile), - path: '/api/mcp/servers', - method: 'PUT', - body: { servers } - }) -} - -/** Start an MCP OAuth flow and return the authorization URL. */ -export function authMcpServer(name: string, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`, - method: 'POST', - timeoutMs: 60_000 - }) -} - -export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}` - }) -} - -/** Cancel an in-flight MCP OAuth flow server-side, freeing the per-server - * "already in progress" slot so a retry doesn't 409. */ -export function cancelMcpOAuthFlow(flowId: string, profile?: null | string): Promise<{ ok: boolean; status: string }> { - return hermesApi<{ ok: boolean; status: string }>({ - ...profileScoped(profile), - path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`, - method: 'DELETE' - }) -} - -// The optional trailing `profile` on every capability fetcher below is the -// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP -// panels configure ANY profile without swapping the app-wide active profile. -// Omitting it (every pre-existing caller) means `profileScoped(undefined)` -// falls back to the app-wide `_apiProfile`, so behavior is byte-identical. -export function getToolsets(profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: '/api/tools/toolsets' - }) -} - -export function setToolsetEnabled( - name: string, - enabled: boolean, - profile?: ProfileScope -): Promise<{ ok: boolean; name: string; enabled: boolean }> { - return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ - ...capabilityScoped(profile), - path: `/api/tools/toolsets/${encodeURIComponent(name)}`, - method: 'PUT', - body: { enabled } - }) -} - -export function getToolsetConfig(name: string, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/tools/toolsets/${encodeURIComponent(name)}/config` - }) -} - -export function getToolsetModels( - name: string, - provider?: string, - profile?: ProfileScope -): Promise { - const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : '' - - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}` - }) -} - -export function selectToolsetModel( - name: string, - model: string, - provider?: string, - profile?: ProfileScope -): Promise<{ ok: boolean; name: string; model: string }> { - return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({ - ...capabilityScoped(profile), - 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', - profile?: ProfileScope -): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, - method: 'PUT', - body: capability ? { provider, capability } : { provider } - }) -} - -export function runToolsetPostSetup( - name: string, - key: string, - profile?: ProfileScope -): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, - method: 'POST', - body: { key } - }) -} - -export function getTerminalBackends(): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/tools/terminal/backends' - }) -} - -export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> { - return hermesApi<{ ok: boolean; backend: string }>({ - ...profileScoped(), - path: '/api/tools/terminal/backend', - method: 'PUT', - body: { backend } - }) -} - -export function getComputerUseStatus(): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/tools/computer-use/status' - }) -} - -export function grantComputerUsePermissions(): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/tools/computer-use/permissions/grant', - method: 'POST' - }) -} - -export function getMessagingPlatforms(profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/messaging/platforms' - }) -} - -export function updateMessagingPlatform( - platformId: string, - body: MessagingPlatformUpdate, - profile?: null | string -): Promise<{ ok: boolean; platform: string }> { - return hermesApi<{ ok: boolean; platform: string }>({ - ...profileScoped(profile), - path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`, - method: 'PUT', - body - }) -} - -export function testMessagingPlatform( - platformId: string, - profile?: null | string -): Promise { - return hermesApi({ - ...profileScoped(profile), - 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(profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/pairing' - }) -} - -export function approvePairing( - platform: string, - requestId: string, - profile?: null | string -): Promise<{ ok: boolean; user: PairingUser }> { - return hermesApi<{ ok: boolean; user: PairingUser }>({ - ...profileScoped(profile), - 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(profile) } - }) -} - -export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> { - return hermesApi<{ ok: boolean }>({ - ...profileScoped(profile), - path: '/api/pairing/revoke', - method: 'POST', - body: { platform, user_id: userId, ...profileScoped(profile) } - }) -} - -// -- 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 hermesApi({ - ...profileScoped(), - path: '/api/webhooks' - }) -} - -export function enableWebhooks(): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/webhooks/enable', - method: 'POST' - }) -} - -export function createWebhook(body: WebhookCreatePayload): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/webhooks', - method: 'POST', - body - }) -} - -export function deleteWebhook(name: string): Promise<{ ok: boolean }> { - return hermesApi<{ 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 hermesApi<{ 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 hermesApi({ - ...profileScoped(), - ...connectionScoped(), - path: `/api/cron/jobs${suffix}`, - timeoutMs: STARTUP_REQUEST_TIMEOUT_MS - }) -} - -export function getCronJob(jobId: string): Promise { - return hermesApi({ - ...profileScoped(), - ...connectionScoped(), - path: `/api/cron/jobs/${encodeURIComponent(jobId)}` - }) -} - -export async function getCronJobRuns(jobId: string, limit = 20): Promise { - const { runs } = await hermesApi<{ runs: SessionInfo[] }>({ - ...profileScoped(), - ...connectionScoped(), - 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 hermesApi<{ targets: CronDeliveryTarget[] }>({ - ...profileScoped(), - ...connectionScoped(), - path: '/api/cron/delivery-targets' - }) - - return targets ?? [] -} - -export function createCronJob(body: CronJobCreatePayload): Promise { - return hermesApi({ - ...profileScoped(), - ...connectionScoped(), - path: '/api/cron/jobs', - method: 'POST', - body - }) -} - -export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { - return hermesApi({ - ...profileScoped(), - ...connectionScoped(), - path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, - method: 'PUT', - body: { updates } - }) -} - -export function pauseCronJob(jobId: string): Promise { - return hermesApi({ - ...profileScoped(), - ...connectionScoped(), - path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, - method: 'POST' - }) -} - -export function resumeCronJob(jobId: string): Promise { - return hermesApi({ - ...profileScoped(), - ...connectionScoped(), - path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, - method: 'POST' - }) -} - -export function triggerCronJob(jobId: string): Promise { - return hermesApi({ - ...profileScoped(), - ...connectionScoped(), - path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, - method: 'POST', - timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS - }) -} - -export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { - return hermesApi<{ ok: boolean }>({ - ...profileScoped(), - ...connectionScoped(), - 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 hermesApi<{ blueprints: AutomationBlueprint[] }>({ - ...profileScoped(), - ...connectionScoped(), - path: '/api/cron/blueprints', - timeoutMs: STARTUP_REQUEST_TIMEOUT_MS - }) -} - -export function instantiateAutomationBlueprint( - body: { blueprint: string; values: Record }, - profile: string -): Promise { - return hermesApi({ - ...profileScoped(), - ...connectionScoped(), - path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, - method: 'POST', - body - }) -} - -export function getProfiles(): Promise { - return hermesApi({ - path: '/api/profiles', - timeoutMs: STARTUP_REQUEST_TIMEOUT_MS - }) -} - -export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> { - return hermesApi<{ 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 hermesApi<{ 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 hermesApi<{ ok: boolean; path: string }>({ - path: `/api/profiles/${encodeURIComponent(name)}`, - method: 'DELETE' - }) -} - -export function getProfileSoul(name: string): Promise { - return hermesApi({ - path: `/api/profiles/${encodeURIComponent(name)}/soul` - }) -} - -export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> { - return hermesApi<{ ok: boolean }>({ - path: `/api/profiles/${encodeURIComponent(name)}/soul`, - method: 'PUT', - body: { content } - }) -} - -export function getProfileSetupCommand(name: string): Promise { - return hermesApi({ - 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 hermesApi<{ 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 hermesApi<{ 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, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}` - }) -} - -export function getGlobalModelOptions( - opts?: { - refresh?: boolean - includeUnconfigured?: boolean - explicitOnly?: boolean - }, - profile?: null | string -): 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 hermesApi({ - ...profileScoped(profile), - 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, - profile?: null | string -): Promise { - return hermesApi({ - ...profileScoped(profile), - path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` - }) -} - -export function setGlobalModel( - provider: string, - model: string -): Promise<{ ok: boolean; provider: string; model: string }> { - return hermesApi<{ ok: boolean; provider: string; model: string }>({ - ...profileScoped(), - path: '/api/model/set', - method: 'POST', - body: { - scope: 'main', - provider, - model - } - }) -} - -export function getAuxiliaryModels(profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/model/auxiliary' - }) -} - -export function getMoaModels(profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/model/moa' - }) -} - -export function saveMoaModels( - body: MoaConfigResponse, - profile?: null | string -): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/model/moa', - method: 'PUT', - body - }) -} - -export function setModelAssignment( - body: ModelAssignmentRequest, - profile?: null | string -): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/model/set', - method: 'POST', - body - }) -} - -export function restartGateway(): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/gateway/restart', - method: 'POST' - }) -} - -export function updateHermes(): Promise { - return hermesApi({ - ...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 hermesApi({ - ...profileScoped(), - path: `/api/hermes/update/check${force ? '?force=true' : ''}` - }) -} - -export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` - }) -} - -export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { - return hermesApi({ - 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 hermesApi({ - ...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(profile?: null | string): Promise { - return hermesApi({ - path: '/api/audio/elevenlabs/voices', - ...profileScoped(profile) - }) -} - -// --------------------------------------------------------------------------- -// 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(profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: '/api/skills/hub/sources', - timeoutMs: HUB_REQUEST_TIMEOUT_MS - }) -} - -export function searchSkillsHub( - query: string, - source = 'all', - limit = 20, - profile?: null | string -): Promise { - const params = new URLSearchParams({ q: query, source, limit: String(limit) }) - - return hermesApi({ - ...profileScoped(profile), - path: `/api/skills/hub/search?${params.toString()}`, - timeoutMs: HUB_REQUEST_TIMEOUT_MS - }) -} - -export function previewSkillHub(identifier: string, profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, - timeoutMs: HUB_REQUEST_TIMEOUT_MS - }) -} - -export function scanSkillHub(identifier: string, profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), - path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`, - timeoutMs: HUB_REQUEST_TIMEOUT_MS - }) -} - -export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: '/api/skills/hub/install', - method: 'POST', - body: { identifier } - }) -} - -export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: '/api/skills/hub/uninstall', - method: 'POST', - body: { name } - }) -} - -export function updateSkillsFromHub(profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - 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 hermesApi<{ servers: McpServerSummary[] }>({ - ...profileScoped(), - path: '/api/mcp/servers' - }) -} - -/** Add one server to `mcp_servers` (validated + name-collision-checked - * server-side — the same endpoint the dashboard's add form uses). */ -export function addMcpServer(body: { - name: string - url?: string - command?: string - args?: string[] - env?: Record - auth?: string -}): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/mcp/servers', - method: 'POST', - body - }) -} - -/** Remove one server from `mcp_servers` (the inline setup card's rollback - * when a directory install is cancelled after the config write). */ -export function removeMcpServer(name: string): Promise<{ ok: boolean }> { - return hermesApi<{ ok: boolean }>({ - ...profileScoped(), - path: `/api/mcp/servers/${encodeURIComponent(name)}`, - method: 'DELETE' - }) -} - -export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> { - return hermesApi<{ ok: boolean }>({ - ...profileScoped(), - path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, - method: 'PUT', - body: { enabled } - }) -} - -export function getMcpCatalog(profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ - ...capabilityScoped(profile), - path: '/api/mcp/catalog' - }) -} - -/** `gh` CLI presence + auth state, for the composer's GitHub skill pill - * (GitHub is deliberately not an MCP — the github/* skills are the - * integration). Backend caches for 5 minutes; `refresh` bypasses. */ -export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> { - return hermesApi<{ available: boolean; authenticated: boolean }>({ - ...profileScoped(), - path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}` - }) -} - -export function installMcpCatalogEntry( - name: string, - env: Record = {}, - profile?: ProfileScope -): 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 }>({ - ...capabilityScoped(profile), - 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 hermesApi({ - ...profileScoped(), - path: '/api/memory' - }) -} - -export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> { - return hermesApi<{ ok: boolean; deleted: string[] }>({ - ...profileScoped(), - path: '/api/memory/reset', - method: 'POST', - body: { target } - }) -} - -export function getCuratorStatus(): Promise { - return hermesApi({ - ...profileScoped(), - path: '/api/curator' - }) -} - -export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> { - return hermesApi<{ ok: boolean; paused: boolean }>({ - ...profileScoped(), - path: '/api/curator/paused', - method: 'PUT', - body: { paused } - }) -} - -export function runCurator(): Promise { - return hermesApi({ - ...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 hermesApi({ path: '/api/ops/doctor', method: 'POST', body: {} }) -} - -export function runSecurityAudit(): Promise { - return hermesApi({ path: '/api/ops/security-audit', method: 'POST', body: {} }) -} - -export function runBackup(): Promise { - return hermesApi({ - path: '/api/ops/backup', - method: 'POST', - body: {} - }) -} - -export function runDebugShare(): Promise { - return hermesApi({ - path: '/api/ops/debug-share', - method: 'POST', - body: {}, - // Synchronous upload of report + logs to the paste service. - timeoutMs: 120_000 - }) -}