Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,160 @@
|
||||
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.
|
||||
* capabilityScoped() now emits an explicit `connectionId` for EVERY object
|
||||
* pin — `'local'` included — so a pin always overrides the ambient tag spread
|
||||
* underneath it. (It used to omit the key for 'local', which made the pin
|
||||
* unable to beat the ambient tag; helpers then had to bypass this wrapper.) */
|
||||
export function hermesApi<T>(request: HermesApiRequest): Promise<T> {
|
||||
return window.hermesDesktop.api<T>({ ...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. A non-empty connection id —
|
||||
// `'local'` INCLUDED — is sent through so Electron's registry resolver
|
||||
// owns the routing. Dropping the `'local'` pin (the pre-#91564 behavior,
|
||||
// when an absent id always meant the local pool) silently re-routes a
|
||||
// "This device" pick to the registry PRIMARY once that primary is a
|
||||
// remote/cloud/ssh gateway: the v1 fallback route treats a remote registry
|
||||
// primary as global-remote, so the explicit pin is the ONLY way back to
|
||||
// this machine (see apiRequestRegistryConnectionId in Electron main).
|
||||
export type ProfileScope = undefined | 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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
return { ...profileScoped(scope), ...connectionScoped() }
|
||||
}
|
||||
|
||||
/** Stable cache-key for a capability scope: `profile` for the ambient/legacy
|
||||
* path, `connectionId::profile` for ANY explicit pin — `local` included. An
|
||||
* explicit "This device" pick and the ambient path are no longer guaranteed
|
||||
* to hit the same backend (a remote registry PRIMARY makes the ambient path
|
||||
* remote), so sharing the bare-profile cache row between them painted one
|
||||
* machine's config under the other's scope (AGENTS.md scope-in-key rule). */
|
||||
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}::${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
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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<StatusResponse> {
|
||||
return hermesApi<StatusResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function getLogs(params: {
|
||||
component?: string
|
||||
file?: string
|
||||
level?: string
|
||||
lines?: number
|
||||
search?: string
|
||||
}): Promise<LogsResponse> {
|
||||
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<LogsResponse>({
|
||||
...profileScoped(),
|
||||
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfig(profile?: string): Promise<HermesConfig> {
|
||||
return hermesApi<HermesConfig>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/config',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigRecord(profile?: ProfileScope): Promise<HermesConfigRecord> {
|
||||
return window.hermesDesktop.api<HermesConfigRecord>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/config'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
|
||||
return hermesApi<HermesConfigRecord>({
|
||||
...profileScoped(),
|
||||
path: '/api/config/defaults',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigSchema(profile?: null | string): Promise<ConfigSchemaResponse> {
|
||||
return hermesApi<ConfigSchemaResponse>({
|
||||
...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 }
|
||||
})
|
||||
}
|
||||
|
||||
/** Capability-scoped counterpart of saveHermesConfig — writes the config of
|
||||
* the profile/connection the Capabilities scope selector points at (possibly
|
||||
* on another registered gateway), mirroring getHermesConfigRecord. */
|
||||
export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: ProfileScope): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/config',
|
||||
method: 'PUT',
|
||||
body: { config }
|
||||
})
|
||||
}
|
||||
|
||||
export function getEnvVars(profile?: null | string): Promise<Record<string, EnvVarInfo>> {
|
||||
return hermesApi<Record<string, EnvVarInfo>>({
|
||||
...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<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/custom-endpoints'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: endpoint
|
||||
})
|
||||
}
|
||||
|
||||
export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointValidationResponse> {
|
||||
return hermesApi<CustomEndpointValidationResponse>({
|
||||
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<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
return hermesApi<OAuthProvidersResponse>({
|
||||
...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<OAuthStartResponse> {
|
||||
return window.hermesDesktop.api<OAuthStartResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
|
||||
return hermesApi<OAuthSubmitResponse>({
|
||||
...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<OAuthPollResponse> {
|
||||
return window.hermesDesktop.api<OAuthPollResponse>({
|
||||
...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'
|
||||
})
|
||||
}
|
||||
@@ -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 (<HERMES_HOME>/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<CronJob[]> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
return hermesApi<CronJob[]>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs${suffix}`,
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
|
||||
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<CronDeliveryTarget[]> {
|
||||
const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/delivery-targets'
|
||||
})
|
||||
|
||||
return targets ?? []
|
||||
}
|
||||
|
||||
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/jobs',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
|
||||
method: 'PUT',
|
||||
body: { updates }
|
||||
})
|
||||
}
|
||||
|
||||
export function pauseCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function resumeCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function triggerCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...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<string, string> },
|
||||
profile: string
|
||||
): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
|
||||
|
||||
import { hermesApi, profileScoped } from './client'
|
||||
|
||||
// The desktop surface of the managed llama.cpp runtime: status/catalog
|
||||
// reads, download/install/activate jobs, and server control.
|
||||
|
||||
export function getLocalModelsStatus(): Promise<LocalModelsStatus> {
|
||||
return hermesApi<LocalModelsStatus>({
|
||||
...profileScoped(),
|
||||
path: '/api/local-models/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function getLocalHardware(): Promise<LocalHardware> {
|
||||
return hermesApi<LocalHardware>({
|
||||
...profileScoped(),
|
||||
path: '/api/local-models/hardware'
|
||||
})
|
||||
}
|
||||
|
||||
export function getLocalCatalog(): Promise<{ models: LocalCatalogModel[] }> {
|
||||
return hermesApi<{ models: LocalCatalogModel[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/local-models/catalog'
|
||||
})
|
||||
}
|
||||
|
||||
export function installLocalRuntime(backend?: string): Promise<{ backend: string; job_id: string; tag: string }> {
|
||||
return hermesApi<{ backend: string; job_id: string; tag: string }>({
|
||||
...profileScoped(),
|
||||
body: { backend: backend ?? null },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/runtime/install'
|
||||
})
|
||||
}
|
||||
|
||||
export interface QuickstartResponse {
|
||||
display_name: string
|
||||
download_bytes: number
|
||||
job_id: string
|
||||
model_id: string
|
||||
needs_download: boolean
|
||||
needs_runtime: boolean
|
||||
}
|
||||
|
||||
export function quickstartLocalModels(modelId?: string): Promise<QuickstartResponse> {
|
||||
return hermesApi<QuickstartResponse>({
|
||||
...profileScoped(),
|
||||
body: { model_id: modelId ?? null },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/quickstart'
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadLocalModel(modelId: string): Promise<{ already_downloaded?: boolean; job_id: null | string }> {
|
||||
return hermesApi<{ already_downloaded?: boolean; job_id: null | string }>({
|
||||
...profileScoped(),
|
||||
body: { model_id: modelId },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/download'
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteLocalModel(modelId: string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
method: 'DELETE',
|
||||
path: `/api/local-models/models/${encodeURIComponent(modelId)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getLocalRuntimeJob(jobId: string): Promise<LocalRuntimeJob> {
|
||||
return hermesApi<LocalRuntimeJob>({
|
||||
...profileScoped(),
|
||||
path: `/api/local-models/jobs/${encodeURIComponent(jobId)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getLocalModelsJobs(): Promise<{ jobs: LocalRuntimeJob[] }> {
|
||||
return hermesApi<{ jobs: LocalRuntimeJob[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/local-models/jobs'
|
||||
})
|
||||
}
|
||||
|
||||
export function activateLocalModel(modelId: string): Promise<{ job_id: string }> {
|
||||
return hermesApi<{ job_id: string }>({
|
||||
...profileScoped(),
|
||||
body: { model_id: modelId },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/activate'
|
||||
})
|
||||
}
|
||||
|
||||
export function ejectLocalModel(modelId: string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
body: { model_id: modelId },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/eject'
|
||||
})
|
||||
}
|
||||
|
||||
export function setLocalServer(action: 'start' | 'stop'): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
body: { action },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/server'
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hugging Face browser + sideload ─────────────────────────────
|
||||
|
||||
export interface HFSearchHit {
|
||||
repo: string
|
||||
downloads: number
|
||||
likes: number
|
||||
updated: string
|
||||
gated: boolean
|
||||
}
|
||||
|
||||
export interface HFFileGroup {
|
||||
label: string
|
||||
paths: string[]
|
||||
total_bytes: number
|
||||
fit: 'fits-gpu' | 'needs-ram' | 'too-big' | 'unknown'
|
||||
}
|
||||
|
||||
export function searchHFModels(q: string, limit = 20): Promise<{ hits: HFSearchHit[] }> {
|
||||
return hermesApi<{ hits: HFSearchHit[] }>({
|
||||
...profileScoped(),
|
||||
path: `/api/local-models/search?q=${encodeURIComponent(q)}&limit=${limit}`
|
||||
})
|
||||
}
|
||||
|
||||
export function listHFRepoFiles(repo: string): Promise<{ files: HFFileGroup[] }> {
|
||||
return hermesApi<{ files: HFFileGroup[] }>({
|
||||
...profileScoped(),
|
||||
path: `/api/local-models/search/files?repo=${encodeURIComponent(repo)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadBrowsedModel(
|
||||
repo: string,
|
||||
paths: string[]
|
||||
): Promise<{ already_downloaded?: boolean; job_id: null | string; model_id: string }> {
|
||||
return hermesApi<{ already_downloaded?: boolean; job_id: null | string; model_id: string }>({
|
||||
...profileScoped(),
|
||||
body: { paths, repo },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/download-browsed'
|
||||
})
|
||||
}
|
||||
|
||||
export function sideloadLocalModel(
|
||||
path: string
|
||||
): Promise<{ already_present?: boolean; model_id: string; ok: boolean }> {
|
||||
return hermesApi<{ already_present?: boolean; model_id: string; ok: boolean }>({
|
||||
...profileScoped(),
|
||||
body: { path },
|
||||
method: 'POST',
|
||||
path: '/api/local-models/sideload'
|
||||
})
|
||||
}
|
||||
@@ -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<McpTestResult> {
|
||||
return window.hermesDesktop.api<McpTestResult>({
|
||||
...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<string, Record<string, unknown>>,
|
||||
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<McpOAuthFlow> {
|
||||
return window.hermesDesktop.api<McpOAuthFlow>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`,
|
||||
method: 'POST',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
}
|
||||
|
||||
export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise<McpOAuthFlow> {
|
||||
return window.hermesDesktop.api<McpOAuthFlow>({
|
||||
...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<string, string>
|
||||
auth?: string
|
||||
}): Promise<McpServerSummary> {
|
||||
return hermesApi<McpServerSummary>({
|
||||
...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<McpCatalogResponse> {
|
||||
return window.hermesDesktop.api<McpCatalogResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/mcp/catalog'
|
||||
})
|
||||
}
|
||||
|
||||
export function installMcpCatalogEntry(
|
||||
name: string,
|
||||
env: Record<string, string> = {},
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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<MessagingPlatformsResponse> {
|
||||
return hermesApi<MessagingPlatformsResponse>({
|
||||
...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<MessagingPlatformTestResponse> {
|
||||
return hermesApi<MessagingPlatformTestResponse>({
|
||||
...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<PairingResponse> {
|
||||
return hermesApi<PairingResponse>({
|
||||
...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<WebhooksResponse> {
|
||||
return hermesApi<WebhooksResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks'
|
||||
})
|
||||
}
|
||||
|
||||
export function enableWebhooks(): Promise<WebhookEnableResponse> {
|
||||
return hermesApi<WebhookEnableResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks/enable',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
|
||||
return hermesApi<WebhookCreateResponse>({
|
||||
...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 }
|
||||
})
|
||||
}
|
||||
@@ -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<ModelInfoResponse> {
|
||||
return hermesApi<ModelInfoResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/info',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise<AnalyticsResponse> {
|
||||
return window.hermesDesktop.api<AnalyticsResponse>({
|
||||
...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<ModelOptionsResponse> {
|
||||
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<ModelOptionsResponse>({
|
||||
...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<RecommendedDefaultModel> {
|
||||
return hermesApi<RecommendedDefaultModel>({
|
||||
...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<AuxiliaryModelsResponse> {
|
||||
return hermesApi<AuxiliaryModelsResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/auxiliary'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMoaModels(profile?: null | string): Promise<MoaConfigResponse> {
|
||||
return hermesApi<MoaConfigResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/moa'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMoaModels(
|
||||
body: MoaConfigResponse,
|
||||
profile?: null | string
|
||||
): Promise<MoaConfigResponse & { ok: boolean }> {
|
||||
return hermesApi<MoaConfigResponse & { ok: boolean }>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/moa',
|
||||
method: 'PUT',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelAssignment(
|
||||
body: ModelAssignmentRequest,
|
||||
profile?: null | string
|
||||
): Promise<ModelAssignmentResponse> {
|
||||
return hermesApi<ModelAssignmentResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { setApiRequestConnection, setApiRequestProfile } from '@/hermes'
|
||||
|
||||
import { activeConnection } from './plugins'
|
||||
|
||||
// desktop.getConnection/getConnectionFor are IPC round-trips into the main
|
||||
// process with no timeout of their own (#93454). A wedged main-process
|
||||
// round-trip must reject instead of hanging pluginSocket's connect() forever.
|
||||
describe('activeConnection connection timeout (#93454)', () => {
|
||||
afterEach(() => {
|
||||
setApiRequestConnection(null)
|
||||
setApiRequestProfile(null)
|
||||
Reflect.deleteProperty(window, 'hermesDesktop')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects instead of hanging forever when getConnection() wedges', async () => {
|
||||
vi.useFakeTimers()
|
||||
setApiRequestProfile('coder')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { getConnection: vi.fn(() => new Promise(() => undefined)) }
|
||||
})
|
||||
|
||||
const pending = expect(activeConnection()).rejects.toThrow('Timed out connecting to profile "coder"')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await pending
|
||||
})
|
||||
|
||||
it('rejects instead of hanging forever when getConnectionFor() wedges', async () => {
|
||||
vi.useFakeTimers()
|
||||
setApiRequestConnection('gw-tailscale')
|
||||
setApiRequestProfile('research')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getConnection: vi.fn(() => new Promise(() => undefined)),
|
||||
getConnectionFor: vi.fn(() => new Promise(() => undefined))
|
||||
}
|
||||
})
|
||||
|
||||
const pending = expect(activeConnection()).rejects.toThrow('Timed out connecting to profile "research"')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await pending
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
|
||||
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
|
||||
|
||||
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.
|
||||
*
|
||||
* Both branches are IPC round-trips into the main process with no timeout of
|
||||
* their own (#93454) — a wedged main-process round-trip otherwise hangs
|
||||
* pluginSocket's connect() forever instead of falling back to the polling
|
||||
* fallback every consumer already has. Bound the same way store/gateway's
|
||||
* openSecondary bounds the same *For/plain pair.
|
||||
*
|
||||
* Exported for tests. */
|
||||
export async function activeConnection(): Promise<HermesConnection> {
|
||||
const getConnectionFor = window.hermesDesktop.getConnectionFor
|
||||
const connectionId = getApiRequestConnection()
|
||||
const profile = getApiRequestProfile()
|
||||
|
||||
if (connectionId && getConnectionFor) {
|
||||
return withTimeout(
|
||||
getConnectionFor({ connectionId, profile }),
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
`Timed out connecting to profile "${profile}"`
|
||||
)
|
||||
}
|
||||
|
||||
return withTimeout(
|
||||
window.hermesDesktop.getConnection(profile),
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
`Timed out connecting to profile "${profile}"`
|
||||
)
|
||||
}
|
||||
|
||||
/** 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/<id>`.
|
||||
// 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/<pluginId>`
|
||||
* ('/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<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
|
||||
if (!window.hermesDesktop?.api) {
|
||||
throw new Error('Hermes desktop bridge unavailable')
|
||||
}
|
||||
|
||||
const suffix = pluginPathSuffix('pluginRest', path)
|
||||
|
||||
return hermesApi<T>({
|
||||
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/<pluginId>` ('/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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
ProfileCreatePayload,
|
||||
ProfileDesktopOverlay,
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
|
||||
export function getProfiles(): Promise<ProfilesResponse> {
|
||||
return hermesApi<ProfilesResponse>({
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// Explicit (connection, profile) pin for a profile that lives on a gateway
|
||||
// other than the foreground one — the fleet profile rail edits a remote
|
||||
// square's SOUL/name in place. capabilityScoped now forwards a `'local'` pin
|
||||
// itself (it must, or a remote registry PRIMARY absorbs "This device" reads),
|
||||
// so this is a plain alias kept for the call sites' self-documenting name.
|
||||
function profileOwnerScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
|
||||
return capabilityScoped(scope)
|
||||
}
|
||||
|
||||
export function renameProfile(
|
||||
name: string,
|
||||
newName: string,
|
||||
scope?: ProfileScope
|
||||
): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
return hermesApi<{ name: string; ok: boolean; path: string }>({
|
||||
...profileOwnerScoped(scope),
|
||||
path: `/api/profiles/${encodeURIComponent(name)}`,
|
||||
method: 'PATCH',
|
||||
body: { new_name: newName }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> {
|
||||
const normalized = name.trim()
|
||||
const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined
|
||||
|
||||
if (!normalized) {
|
||||
return Promise.reject(new Error('Profile name required'))
|
||||
}
|
||||
|
||||
if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') {
|
||||
return Promise.reject(new Error('The default profile cannot be deleted.'))
|
||||
}
|
||||
|
||||
return hermesApi<{ ok: boolean; path: string }>({
|
||||
...profileOwnerScoped(scope),
|
||||
path: `/api/profiles/${encodeURIComponent(normalized)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfileSoul(name: string, scope?: ProfileScope): Promise<ProfileSoul> {
|
||||
return hermesApi<ProfileSoul>({
|
||||
...profileOwnerScoped(scope),
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/soul`
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProfileSoul(name: string, content: string, scope?: ProfileScope): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileOwnerScoped(scope),
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
|
||||
method: 'PUT',
|
||||
body: { content }
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
|
||||
return hermesApi<ProfileSetupCommand>({
|
||||
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<string, string>; 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/gateway-rpc', () => ({ isMissingRestEndpoint: () => false }))
|
||||
vi.mock('@/store/transcript-tail', () => ({ recordTranscriptTail: vi.fn() }))
|
||||
vi.mock('./client', () => ({
|
||||
capabilityScoped: vi.fn(),
|
||||
getApiRequestConnection: vi.fn(() => 'prometheus'),
|
||||
hermesApi: vi.fn(),
|
||||
profileScoped: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const client = await import('./client')
|
||||
|
||||
const { deleteSession, setSessionArchived, setSessionPinnedRemote, setSessionUnreadRemote, listSidebarSessions } =
|
||||
await import('./sessions')
|
||||
|
||||
const hermesApi = vi.mocked(client.hermesApi)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(client.getApiRequestConnection).mockReturnValue('prometheus')
|
||||
})
|
||||
|
||||
describe('deleteSession profile scoping', () => {
|
||||
it('scopes the DELETE to the owning profile in the URL (object owner)', async () => {
|
||||
// Regression: the sidebar "All Profiles" delete sent the profile only via
|
||||
// request.profile, not in the URL. On a remote gateway with no remoteProfile
|
||||
// alias the main-process path rewrite left the URL unscoped, so the backend
|
||||
// opened its own default state.db, missed the row, and returned
|
||||
// {ok:true, already_absent:true} — the row vanished optimistically but was
|
||||
// never deleted and came back on refresh. The URL must carry ?profile=.
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
// Mirrors the real capabilityScoped for an object owner (remote-stamped row).
|
||||
vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy', connectionId: 'hermes-pi' })
|
||||
|
||||
await deleteSession('sess-1', { connectionId: 'hermes-pi', profile: 'tommy' })
|
||||
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
method: 'DELETE',
|
||||
path: '/api/sessions/sess-1?profile=tommy',
|
||||
connectionId: 'hermes-pi',
|
||||
profile: 'tommy'
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes the DELETE to the owning profile in the URL (bare string owner)', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
// Bare-string owner: capabilityScoped resolves it to a profile scope.
|
||||
vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy' })
|
||||
|
||||
await deleteSession('sess-2', 'tommy')
|
||||
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
method: 'DELETE',
|
||||
path: '/api/sessions/sess-2?profile=tommy'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the profile query when no owner is known', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
|
||||
await deleteSession('sess-3')
|
||||
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
method: 'DELETE',
|
||||
path: '/api/sessions/sess-3'
|
||||
})
|
||||
expect((hermesApi.mock.calls[0][0] as { path: string }).path).not.toContain('profile=')
|
||||
})
|
||||
|
||||
it('keeps an explicit local pin routed to the local pool', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
// capabilityScoped drops a 'local' connection id by design; sessionScoped
|
||||
// must re-add it so the request stays pinned to this device.
|
||||
vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy' })
|
||||
|
||||
await deleteSession('sess-4', { connectionId: 'local', profile: 'tommy' })
|
||||
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
method: 'DELETE',
|
||||
path: '/api/sessions/sess-4?profile=tommy',
|
||||
connectionId: 'local',
|
||||
profile: 'tommy'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSessionArchived profile scoping', () => {
|
||||
it('carries the owning profile in the PATCH body', async () => {
|
||||
// Same class as the unscoped DELETE: the PATCH handler reads its target DB
|
||||
// from body.profile, so archiving a foreign-profile session must send it in
|
||||
// the body, not only as request.profile (Electron routing), or on a remote
|
||||
// gateway the archive lands on the wrong state.db and silently no-ops.
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
|
||||
await setSessionArchived('sess-a', true, 'tommy')
|
||||
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
method: 'PATCH',
|
||||
path: '/api/sessions/sess-a',
|
||||
profile: 'tommy',
|
||||
body: { archived: true, profile: 'tommy' }
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the profile from the body when none is given', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
|
||||
await setSessionArchived('sess-b', false)
|
||||
|
||||
const req = hermesApi.mock.calls[0][0] as { body: Record<string, unknown> }
|
||||
expect(req).toMatchObject({ method: 'PATCH', body: { archived: false } })
|
||||
expect(req.body).not.toHaveProperty('profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSessionPinnedRemote / setSessionUnreadRemote profile scoping', () => {
|
||||
it('carries the owning profile in the pin PATCH body', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
|
||||
await setSessionPinnedRemote('sess-p', true, 'tommy')
|
||||
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
method: 'PATCH',
|
||||
path: '/api/sessions/sess-p',
|
||||
profile: 'tommy',
|
||||
body: { pinned: true, profile: 'tommy' }
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the owning profile in the unread PATCH body', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
|
||||
await setSessionUnreadRemote('sess-u', true, 'tommy')
|
||||
|
||||
expect(hermesApi.mock.calls[0][0]).toMatchObject({
|
||||
method: 'PATCH',
|
||||
path: '/api/sessions/sess-u',
|
||||
profile: 'tommy',
|
||||
body: { unread: true, profile: 'tommy' }
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the profile from the body when none is given', async () => {
|
||||
hermesApi.mockResolvedValue({ ok: true } as never)
|
||||
|
||||
await setSessionPinnedRemote('sess-p2', false)
|
||||
|
||||
const req = hermesApi.mock.calls[0][0] as { body: Record<string, unknown> }
|
||||
expect(req).toMatchObject({ method: 'PATCH', body: { pinned: false } })
|
||||
expect(req.body).not.toHaveProperty('profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listSidebarSessions remote ownership', () => {
|
||||
it('stamps active remote rows so a later resume stays on their gateway', async () => {
|
||||
hermesApi.mockResolvedValue({
|
||||
cron: { sessions: [] },
|
||||
messaging: { sessions: [] },
|
||||
recents: {
|
||||
sessions: [{ id: 'remote-session', profile: 'default', source: 'desktop', title: 'Remote chat' }]
|
||||
}
|
||||
} as never)
|
||||
|
||||
const result = await listSidebarSessions({
|
||||
recentsProfile: 'default',
|
||||
recentsLimit: 40,
|
||||
recentsExclude: [],
|
||||
cronLimit: 20,
|
||||
messagingLimit: 40,
|
||||
messagingExclude: []
|
||||
})
|
||||
|
||||
expect(result.recents.sessions[0]).toMatchObject({ connection_id: 'prometheus', id: 'remote-session' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,599 @@
|
||||
import { isMissingRestEndpoint } from '@/lib/gateway-rpc'
|
||||
import { maybeBackfillLegacySessionOwners } from '@/lib/legacy-session-owner-backfill'
|
||||
import { stampRowsWithOwningConnection } from '@/lib/session-owner-stamp'
|
||||
import { recordTranscriptTail } from '@/store/transcript-tail'
|
||||
import type {
|
||||
PaginatedSessions,
|
||||
SessionInfo,
|
||||
SessionMessage,
|
||||
SessionMessagesResponse,
|
||||
SessionSearchResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, getApiRequestConnection, hermesApi, type ProfileScope, profileScoped } from './client'
|
||||
|
||||
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
function sessionScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
|
||||
if (scope === undefined || scope === null) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const scoped = capabilityScoped(scope)
|
||||
|
||||
if (typeof scope === 'object' && scope.connectionId?.trim() === 'local') {
|
||||
return { ...scoped, connectionId: 'local' }
|
||||
}
|
||||
|
||||
return scoped
|
||||
}
|
||||
|
||||
function sessionScopeQuery(scope?: ProfileScope): string {
|
||||
const profile = sessionScoped(scope).profile
|
||||
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The active registered gateway owns every row it returns, but its HTTP APIs
|
||||
* correctly know nothing about this Desktop-local registry id. Preserve an
|
||||
* explicit owner from a multi-source response; otherwise stamp the active
|
||||
* non-local source so a later resume cannot fall back to a same-named local
|
||||
* profile. Delegates to the canonical row-stamp helper so this stays the ONE
|
||||
* write shape for connection_id on backend-returned rows.
|
||||
*/
|
||||
function stampActiveConnectionOwner(sessions: SessionInfo[]): SessionInfo[] {
|
||||
// Durable half of the same ownership contract (#94724): enumeration under
|
||||
// registry topology triggers the one-shot server-side owner backfill for
|
||||
// the serving store when its owner is a single match. Fire-and-forget;
|
||||
// idempotent server-side; never blocks or fails the list that triggered it.
|
||||
maybeBackfillLegacySessionOwners()
|
||||
|
||||
return stampRowsWithOwningConnection(sessions, getApiRequestConnection())
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a page to its window WITHOUT discarding pinned rows.
|
||||
*
|
||||
* The list endpoints deliberately back-fill pinned conversations past their
|
||||
* LIMIT — a pin means "always reachable", so an aged-out pinned chat is
|
||||
* appended after the recency window. A plain `slice(0, limit)` throws exactly
|
||||
* those rows away again, which is why pins silently stopped rendering past
|
||||
* some count: the sidebar could only ever show the pins that happened to fall
|
||||
* inside the most-recent page.
|
||||
*/
|
||||
function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] {
|
||||
if (sessions.length <= limit) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
const recent = sessions.slice(0, limit)
|
||||
|
||||
return [...recent, ...sessions.slice(limit).filter(session => session.pinned)]
|
||||
}
|
||||
|
||||
export async function listSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await hermesApi<PaginatedSessions>({
|
||||
...profileScoped(),
|
||||
path:
|
||||
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
sessions: pageWindow(stampActiveConnectionOwner(result.sessions), limit),
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Unified, read-only session list aggregated across ALL profiles. Served by the
|
||||
// primary backend straight off each profile's state.db — no per-profile backend
|
||||
// is spawned. Single-profile users get the same rows as listSessions(), tagged
|
||||
// profile="default".
|
||||
// Source scoping lets callers split the unified list into independent slices:
|
||||
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
|
||||
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
|
||||
// consumes the whole recents page and starves real conversations.
|
||||
export interface SessionSourceFilter {
|
||||
source?: string
|
||||
excludeSources?: string[]
|
||||
}
|
||||
|
||||
export async function listAllProfileSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent',
|
||||
profile: 'all' | (string & {}) = 'all',
|
||||
filter: SessionSourceFilter = {}
|
||||
): Promise<PaginatedSessions> {
|
||||
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
|
||||
|
||||
const excludeParam = filter.excludeSources?.length
|
||||
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
|
||||
: ''
|
||||
|
||||
const result = await hermesApi<PaginatedSessions>({
|
||||
...profileScoped(),
|
||||
path:
|
||||
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
sessions: pageWindow(stampActiveConnectionOwner(result.sessions), limit),
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Batched sidebar slices in one request: recents (scoped to the active profile),
|
||||
// cron, and messaging. The backend opens each profile's state.db once and runs
|
||||
// all three filtered queries, replacing three separate listAllProfileSessions
|
||||
// calls that each reopened + re-counted every profile DB per refresh. Electron
|
||||
// splices remote profiles per slice (see interceptSessionRequestForRemote).
|
||||
export interface SidebarSessionSlice {
|
||||
sessions: SessionInfo[]
|
||||
/** Per-profile "the window came back full, more rows exist on disk" flags —
|
||||
* what pagination needs, without a COUNT(*) per profile DB per refresh. */
|
||||
profiles_truncated?: Record<string, boolean>
|
||||
/** Per-profile tokens and spend over every session, not just this window.
|
||||
* Absent from the legacy per-slice endpoint, which has no aggregate. */
|
||||
profiles_usage?: Record<string, { cost_usd: number; tokens: number }>
|
||||
/** Profiles whose scan for THIS slice failed. Batched `/sidebar` stamps the
|
||||
* same profile errors on every slice (one DB open). Legacy per-slice calls
|
||||
* stamp only the slice that actually failed, so a cron I/O error cannot
|
||||
* carry-forward recents. */
|
||||
errors?: Array<{ profile: string; error: string }>
|
||||
}
|
||||
|
||||
/** Which profiles filled their per-profile window in a returned page. The
|
||||
* legacy per-slice endpoint doesn't report this, so derive it from the rows:
|
||||
* a profile at (or over) the cap still has more on disk. Pinned rows are
|
||||
* discounted — they're back-filled past the LIMIT, so counting them fakes a
|
||||
* full page and leaves a "Load more" that can never resolve. */
|
||||
function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record<string, boolean> {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.profile || 'default'
|
||||
|
||||
counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1))
|
||||
}
|
||||
|
||||
return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap]))
|
||||
}
|
||||
|
||||
export interface SidebarSessionsResponse {
|
||||
recents: SidebarSessionSlice
|
||||
cron: SidebarSessionSlice
|
||||
messaging: SidebarSessionSlice
|
||||
errors?: Array<{ profile: string; error: string }>
|
||||
}
|
||||
|
||||
export interface SidebarSessionsRequest {
|
||||
recentsProfile: 'all' | (string & {})
|
||||
recentsLimit: number
|
||||
recentsExclude: string[]
|
||||
cronLimit: number
|
||||
messagingLimit: number
|
||||
messagingExclude: string[]
|
||||
}
|
||||
|
||||
// The batched /sidebar endpoint shipped later than the per-slice route, so a
|
||||
// newer desktop can meet an older backend that 404s it ("No such API
|
||||
// endpoint"). Endpoint-missing is a capability signal, not a transient
|
||||
// failure: remember it (per renderer lifetime — a runtime home change reloads
|
||||
// the window and re-probes) and serve every subsequent refresh straight from
|
||||
// the three proven per-slice calls instead of re-probing a known-dead route
|
||||
// once per turn/broadcast.
|
||||
let sidebarBatchEndpointMissing = false
|
||||
|
||||
// Capability flags are per-backend facts. A hard re-home reloads the window
|
||||
// (module state resets naturally), but a soft gateway switch re-dials in
|
||||
// place — the next backend may well have the batched route, so the switch
|
||||
// paths call this to re-probe rather than leak the old backend's capability.
|
||||
export function resetSidebarBatchCapability() {
|
||||
sidebarBatchEndpointMissing = false
|
||||
}
|
||||
|
||||
// Compatibility fallback: reassemble the three sidebar slices from the
|
||||
// per-slice endpoint, mirroring the batched route's semantics (min_messages=1,
|
||||
// archived excluded, recency order; every slice scoped to the caller's profile).
|
||||
// Rides the same Electron remote-splice
|
||||
// interception as the pre-batching desktop, so remote profiles stay correct.
|
||||
async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
|
||||
const [recents, cron, messaging] = await Promise.all([
|
||||
listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, {
|
||||
excludeSources: req.recentsExclude
|
||||
}),
|
||||
listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }),
|
||||
listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, {
|
||||
excludeSources: req.messagingExclude
|
||||
})
|
||||
])
|
||||
|
||||
const recentsErrors = recents.errors ?? []
|
||||
const cronErrors = cron.errors ?? []
|
||||
const messagingErrors = messaging.errors ?? []
|
||||
|
||||
return {
|
||||
recents: {
|
||||
profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit),
|
||||
sessions: recents.sessions,
|
||||
...(recentsErrors.length ? { errors: recentsErrors } : {})
|
||||
},
|
||||
cron: {
|
||||
sessions: cron.sessions,
|
||||
...(cronErrors.length ? { errors: cronErrors } : {})
|
||||
},
|
||||
messaging: {
|
||||
sessions: messaging.sessions,
|
||||
...(messagingErrors.length ? { errors: messagingErrors } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The PR each of these sessions opened, recovered from its own transcript —
|
||||
* for sessions whose recorded branch can't answer (they started on trunk and
|
||||
* did the work in a worktree). Also returns every id it looked at, so the
|
||||
* caller can remember a miss and never ask again. */
|
||||
export function scanSessionPullRequests(
|
||||
ids: string[]
|
||||
): Promise<{ pull_requests: Record<string, { number: number; url: string }>; scanned: string[] }> {
|
||||
return hermesApi<{
|
||||
pull_requests: Record<string, { number: number; url: string }>
|
||||
scanned: string[]
|
||||
}>({
|
||||
path: '/api/profiles/sessions/pull-requests',
|
||||
method: 'POST',
|
||||
body: { ids }
|
||||
})
|
||||
}
|
||||
|
||||
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
|
||||
if (sidebarBatchEndpointMissing) {
|
||||
return listSidebarSessionsLegacy(req)
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
recents_profile: req.recentsProfile,
|
||||
recents_limit: String(Math.max(1, req.recentsLimit)),
|
||||
cron_limit: String(Math.max(1, req.cronLimit)),
|
||||
messaging_limit: String(Math.max(1, req.messagingLimit))
|
||||
})
|
||||
|
||||
if (req.recentsExclude.length) {
|
||||
params.set('recents_exclude', req.recentsExclude.join(','))
|
||||
}
|
||||
|
||||
if (req.messagingExclude.length) {
|
||||
params.set('messaging_exclude', req.messagingExclude.join(','))
|
||||
}
|
||||
|
||||
let result: SidebarSessionsResponse
|
||||
|
||||
try {
|
||||
result = await hermesApi<SidebarSessionsResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
} catch (err) {
|
||||
// Safe to read a 404 as route-missing here: this GET has no path params,
|
||||
// so it cannot 404 on a bad id.
|
||||
if (!isMissingRestEndpoint(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// Older backend without the batched route (desktop/runtime version skew).
|
||||
sidebarBatchEndpointMissing = true
|
||||
|
||||
return listSidebarSessionsLegacy(req)
|
||||
}
|
||||
|
||||
return {
|
||||
recents: {
|
||||
...result.recents,
|
||||
sessions: stampActiveConnectionOwner(result.recents?.sessions ?? []),
|
||||
...(result.errors?.length ? { errors: result.errors } : {})
|
||||
},
|
||||
cron: {
|
||||
...result.cron,
|
||||
sessions: stampActiveConnectionOwner(result.cron?.sessions ?? []),
|
||||
...(result.errors?.length ? { errors: result.errors } : {})
|
||||
},
|
||||
messaging: {
|
||||
...result.messaging,
|
||||
sessions: stampActiveConnectionOwner(result.messaging?.sessions ?? []),
|
||||
...(result.errors?.length ? { errors: result.errors } : {})
|
||||
},
|
||||
errors: result.errors
|
||||
}
|
||||
}
|
||||
|
||||
// Mutations take the owning `profile` so Electron can route them to the correct
|
||||
// remote backend or local profile scope. Omit for the current/default profile.
|
||||
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
// Carry the owning profile IN THE PATCH BODY, mirroring renameSession — the
|
||||
// backend reads its target DB from body.profile (_open_session_db_for_profile).
|
||||
// Passing it only as request.profile (Electron routing) is not enough on a
|
||||
// remote gateway with no remoteProfile alias: the archive lands on the wrong
|
||||
// (default) state.db, no-ops on a missing row, and the archived/unarchived
|
||||
// state silently fails to stick — the same class as the unscoped DELETE.
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived, ...(profile ? { profile } : {}) }
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive
|
||||
// sweep (which runs backend-side, blind to Desktop localStorage) never hides a
|
||||
// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own
|
||||
// display; this only feeds the backend policy.
|
||||
export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
// Owning profile in the PATCH body (see setSessionArchived / renameSession):
|
||||
// the handler reads its target DB from body.profile, so a remote/foreign
|
||||
// profile's pin must travel in the body or it no-ops on the wrong state.db.
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { pinned, ...(profile ? { profile } : {}) }
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror a sidebar unread toggle to the backend read-state watermark
|
||||
// (sessions.last_read_at via SessionDB.set_session_read). Same profile
|
||||
// routing as the other session mutations: a remote session's row lives only
|
||||
// on its remote host, so the owning profile must travel with the request.
|
||||
export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
// Owning profile in the PATCH body (see setSessionArchived / renameSession):
|
||||
// the handler reads its target DB from body.profile, so a remote/foreign
|
||||
// profile's unread toggle must travel in the body or it no-ops on the wrong
|
||||
// state.db.
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { unread, ...(profile ? { profile } : {}) }
|
||||
})
|
||||
}
|
||||
|
||||
export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
return hermesApi<SessionSearchResponse>({
|
||||
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves a single session row by id on one backend (the active profile, or
|
||||
// the given `profile`). The backend resolves exact ids and unique prefixes and
|
||||
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
|
||||
// cross-profile list scan when locating an unknown id's owner.
|
||||
export function getSession(id: string, profile?: ProfileScope): Promise<SessionInfo> {
|
||||
const suffix = sessionScopeQuery(profile)
|
||||
|
||||
return hermesApi<SessionInfo>({
|
||||
...sessionScoped(profile),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
|
||||
})
|
||||
}
|
||||
|
||||
// Reads another profile's transcript. For a remote profile Electron reroutes
|
||||
// this GET to the remote backend (which serves its own state.db); for a local
|
||||
// profile the primary opens that profile's state.db via ?profile=. Omit for
|
||||
// the current/default profile.
|
||||
export function getSessionMessages(
|
||||
id: string,
|
||||
profile?: ProfileScope,
|
||||
page: { limit?: number; offset?: number; order?: 'latest' | 'oldest'; includeCompacted?: boolean } = {}
|
||||
): Promise<SessionMessagesResponse> {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
const sessionScope = sessionScoped(profile)
|
||||
|
||||
if (sessionScope.profile) {
|
||||
query.set('profile', sessionScope.profile)
|
||||
}
|
||||
|
||||
if (page.limit !== undefined) {
|
||||
query.set('limit', String(page.limit))
|
||||
}
|
||||
|
||||
if (page.offset !== undefined) {
|
||||
query.set('offset', String(page.offset))
|
||||
}
|
||||
|
||||
if (page.order) {
|
||||
query.set('order', page.order)
|
||||
}
|
||||
|
||||
if (page.includeCompacted !== undefined) {
|
||||
query.set('include_compacted', String(page.includeCompacted))
|
||||
}
|
||||
|
||||
const suffix = query.size ? `?${query.toString()}` : ''
|
||||
|
||||
return hermesApi<SessionMessagesResponse>({
|
||||
...sessionScope,
|
||||
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial hydration page: enough tail to fill the transcript window a few
|
||||
* times over, small enough that opening a long session doesn't ship (and
|
||||
* convert) hundreds of rows nobody has scrolled to. Older rows load on demand
|
||||
* via `getOlderSessionMessages` when "Show earlier" exhausts the in-memory
|
||||
* store (see app/chat/transcript-backfill).
|
||||
*/
|
||||
export const LATEST_SESSION_MESSAGES_LIMIT = 120
|
||||
|
||||
export function getLatestSessionMessages(id: string, profile?: ProfileScope): Promise<SessionMessagesResponse> {
|
||||
// includeCompacted: durable display history must include rows preserved by
|
||||
// in-place compaction (active=0, compacted=1); without them the transcript
|
||||
// silently ends at the compaction boundary and earlier turns are unreachable.
|
||||
return getSessionMessages(id, profile, {
|
||||
limit: LATEST_SESSION_MESSAGES_LIMIT,
|
||||
order: 'latest',
|
||||
includeCompacted: true
|
||||
}).then(page => {
|
||||
// Record whether the tail was truncated (page came back full) and where
|
||||
// the next older page starts, so "Show earlier" can backfill over REST
|
||||
// (app/chat/transcript-backfill). Keyed under both the requested id and
|
||||
// the resolved id — callers hold either.
|
||||
recordTranscriptTail(id, page, profile)
|
||||
|
||||
if (page.session_id && page.session_id !== id) {
|
||||
recordTranscriptTail(page.session_id, page, profile)
|
||||
}
|
||||
|
||||
return page
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* READ-ONLY stored-transcript lookup that never routes a live session
|
||||
* (#94724 no-owner recovery). Tries the ambient/primary store first, then
|
||||
* probes every registered NON-local connection by id — a REST read of a
|
||||
* backend's own state.db is side-effect free (a miss is a plain 404, no
|
||||
* session is minted or resumed anywhere), so probing across backends is safe
|
||||
* where live routing would be a guess. Returns null when no reachable
|
||||
* backend holds the transcript.
|
||||
*/
|
||||
export async function fetchStoredTranscriptAcrossBackends(id: string): Promise<SessionMessagesResponse | null> {
|
||||
try {
|
||||
return await getLatestSessionMessages(id)
|
||||
} catch {
|
||||
// Not on the ambient store — probe the registered backends below.
|
||||
}
|
||||
|
||||
const { $connectionsRegistry } = await import('@/store/connection-registry-state')
|
||||
|
||||
const connections = ($connectionsRegistry.get()?.connections ?? []) as Array<{ id?: string }>
|
||||
|
||||
for (const connection of connections) {
|
||||
const connectionId = connection.id?.trim()
|
||||
|
||||
if (!connectionId || connectionId === 'local' || connectionId === getApiRequestConnection()) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
return await getLatestSessionMessages(id, { connectionId, profile: 'default' })
|
||||
} catch {
|
||||
// Not on this backend (or it is unreachable); try the next.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of messages OLDER than the `offset` newest rows.
|
||||
*
|
||||
* Backend semantics (`_handle_session_messages` → `SessionDB.get_messages`
|
||||
* with `latest=True`): the offset is measured back from the NEWEST message
|
||||
* and the selected page is returned in chronological order. So after a tail
|
||||
* hydration of N rows, `getOlderSessionMessages(id, profile, N)` returns the
|
||||
* page immediately preceding it, ready to prepend.
|
||||
*
|
||||
* Legacy backends without pagination support return the full transcript and
|
||||
* no `pagination` metadata — callers detect that via the missing field and
|
||||
* treat the response as the complete history (see transcript-backfill).
|
||||
*/
|
||||
export function getOlderSessionMessages(
|
||||
id: string,
|
||||
profile: ProfileScope,
|
||||
offset: number,
|
||||
limit: number = LATEST_SESSION_MESSAGES_LIMIT
|
||||
): Promise<SessionMessagesResponse> {
|
||||
return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' })
|
||||
}
|
||||
|
||||
export async function getAllSessionMessages(
|
||||
id: string,
|
||||
profile?: ProfileScope,
|
||||
options: { maxJsonChars?: number } = {}
|
||||
): Promise<SessionMessagesResponse> {
|
||||
const messages: SessionMessage[] = []
|
||||
const pageSize = 500
|
||||
const maxJsonChars = options.maxJsonChars ?? 32_000_000
|
||||
let jsonChars = 0
|
||||
let offset = 0
|
||||
let resolvedSessionId = id
|
||||
|
||||
while (true) {
|
||||
const page = await getSessionMessages(id, profile, {
|
||||
limit: pageSize,
|
||||
offset,
|
||||
order: 'oldest',
|
||||
includeCompacted: true
|
||||
})
|
||||
|
||||
resolvedSessionId = page.session_id
|
||||
jsonChars += (JSON.stringify(page.messages) ?? '').length
|
||||
|
||||
if (jsonChars > maxJsonChars) {
|
||||
throw new Error(
|
||||
'Session transcript exceeds the Desktop safe-load limit; use the Web Dashboard export for this session.'
|
||||
)
|
||||
}
|
||||
|
||||
messages.push(...page.messages)
|
||||
|
||||
// Legacy backends ignore pagination and return the full transcript.
|
||||
if (!page.pagination || page.messages.length === 0 || page.messages.length < page.pagination.limit) {
|
||||
break
|
||||
}
|
||||
|
||||
offset += page.messages.length
|
||||
}
|
||||
|
||||
return { session_id: resolvedSessionId, messages }
|
||||
}
|
||||
|
||||
export function deleteSession(id: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
|
||||
// Scope the DELETE to the owning profile IN THE URL, mirroring getSession /
|
||||
// getSessionMessages. Passing the profile only via request.profile (which the
|
||||
// Electron main process consumes for backend routing) is NOT enough: on a
|
||||
// remote gateway whose connection has no remoteProfile alias, the main-process
|
||||
// path rewrite leaves the URL unscoped, so the backend opens its OWN (default)
|
||||
// state.db, fails to find another profile's session, and returns
|
||||
// {ok:true, already_absent:true}. The row then vanishes optimistically but was
|
||||
// never deleted and reappears on the next sidebar refresh — the "All Profiles
|
||||
// delete doesn't stick" report. The endpoint already honours ?profile=
|
||||
// (get/rename/messages all send it); DELETE was the one mutation that dropped
|
||||
// it after the api/ split. request.profile stays on for per-profile remote
|
||||
// override + global-remote routing (both re-read/re-append the param).
|
||||
const suffix = sessionScopeQuery(profile)
|
||||
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...sessionScoped(profile),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function renameSession(
|
||||
id: string,
|
||||
title: string,
|
||||
profile?: string | null
|
||||
): Promise<{ ok: boolean; title: string }> {
|
||||
return hermesApi<{ ok: boolean; title: string }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { title, ...(profile ? { profile } : {}) }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type {
|
||||
OfficialSkillInfo,
|
||||
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<SkillInfo[]> {
|
||||
return window.hermesDesktop.api<SkillInfo[]>({
|
||||
...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<StarmapGraph> {
|
||||
return hermesApi<StarmapGraph>({
|
||||
...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<LearningNodeDetail> {
|
||||
return window.hermesDesktop.api<LearningNodeDetail>({
|
||||
...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
|
||||
|
||||
/** The full built-in optional-skills catalog (local checkout scan — fast),
|
||||
* with per-profile installed flags. Feeds the Capabilities Skills list's
|
||||
* "available to install" rows. */
|
||||
export function getOfficialSkills(profile?: ProfileScope): Promise<{ skills: OfficialSkillInfo[] }> {
|
||||
return window.hermesDesktop.api<{ skills: OfficialSkillInfo[] }>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/official'
|
||||
})
|
||||
}
|
||||
|
||||
export function getSkillHubSources(profile?: null | string): Promise<SkillHubSourcesResponse> {
|
||||
return hermesApi<SkillHubSourcesResponse>({
|
||||
...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<SkillHubSearchResponse> {
|
||||
const params = new URLSearchParams({ q: query, source, limit: String(limit) })
|
||||
|
||||
return hermesApi<SkillHubSearchResponse>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/skills/hub/search?${params.toString()}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function previewSkillHub(identifier: string, profile?: ProfileScope): Promise<SkillHubPreview> {
|
||||
return window.hermesDesktop.api<SkillHubPreview>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function scanSkillHub(identifier: string, profile?: null | string): Promise<SkillHubScanResult> {
|
||||
return hermesApi<SkillHubScanResult>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/install',
|
||||
method: 'POST',
|
||||
body: { identifier }
|
||||
})
|
||||
}
|
||||
|
||||
export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/uninstall',
|
||||
method: 'POST',
|
||||
body: { name }
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSkillsFromHub(profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/update',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type {
|
||||
ActionResponse,
|
||||
ActionStatusResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AudioTtsLeaseResponse,
|
||||
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<MemoryProviderConfig> {
|
||||
return hermesApi<MemoryProviderConfig>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMemoryProviderConfig(
|
||||
provider: string,
|
||||
values: Record<string, string>,
|
||||
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<MemoryProviderOAuthStatus> {
|
||||
return hermesApi<MemoryProviderOAuthStatus>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMemoryProviderOAuthStatus(
|
||||
provider: string,
|
||||
profile?: null | string
|
||||
): Promise<MemoryProviderOAuthStatus> {
|
||||
return hermesApi<MemoryProviderOAuthStatus>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory data + curator (parity with `hermes memory` / `hermes curator`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getMemoryStatus(): Promise<MemoryStatusResponse> {
|
||||
return hermesApi<MemoryStatusResponse>({
|
||||
...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<CuratorStatusResponse> {
|
||||
return hermesApi<CuratorStatusResponse>({
|
||||
...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<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/curator/run',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function restartGateway(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/gateway/restart',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateHermes(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...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<BackendUpdateCheckResponse> {
|
||||
return hermesApi<BackendUpdateCheckResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise<ActionStatusResponse> {
|
||||
return window.hermesDesktop.api<ActionStatusResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
|
||||
return hermesApi<AudioTranscriptionResponse>({
|
||||
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<AudioSpeakResponse> {
|
||||
return hermesApi<AudioSpeakResponse>({
|
||||
...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)
|
||||
})
|
||||
}
|
||||
|
||||
// Acquiring a lease pre-loads the configured TTS engine. For local engines
|
||||
// that is a model load and, on a fresh install, a voice download — well past
|
||||
// the default 15s Electron backend timeout.
|
||||
export const AUDIO_TTS_LEASE_REQUEST_TIMEOUT_MS = 180_000
|
||||
|
||||
/**
|
||||
* Tell the backend a speech-output toggle flipped so it can warm the TTS engine
|
||||
* (`active: true`) or release it once no surface needs it (`active: false`).
|
||||
* `lease` names the toggle — `desktop:read-aloud`, `desktop:conversation`.
|
||||
*/
|
||||
export function setTtsLease(lease: string, active: boolean): Promise<AudioTtsLeaseResponse> {
|
||||
return hermesApi<AudioTtsLeaseResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/audio/tts-lease',
|
||||
method: 'POST',
|
||||
body: { active, lease },
|
||||
timeoutMs: AUDIO_TTS_LEASE_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getElevenLabsVoices(profile?: null | string): Promise<ElevenLabsVoicesResponse> {
|
||||
return hermesApi<ElevenLabsVoicesResponse>({
|
||||
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<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({ path: '/api/ops/doctor', method: 'POST', body: {} })
|
||||
}
|
||||
|
||||
export function runSecurityAudit(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({ path: '/api/ops/security-audit', method: 'POST', body: {} })
|
||||
}
|
||||
|
||||
export function runBackup(): Promise<ActionResponse & { archive?: string }> {
|
||||
return hermesApi<ActionResponse & { archive?: string }>({
|
||||
path: '/api/ops/backup',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function runDebugShare(): Promise<DebugShareResponse> {
|
||||
return hermesApi<DebugShareResponse>({
|
||||
path: '/api/ops/debug-share',
|
||||
method: 'POST',
|
||||
body: {},
|
||||
// Synchronous upload of report + logs to the paste service.
|
||||
timeoutMs: 120_000
|
||||
})
|
||||
}
|
||||
@@ -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<ToolsetInfo[]> {
|
||||
return window.hermesDesktop.api<ToolsetInfo[]>({
|
||||
...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<ToolsetConfig> {
|
||||
return window.hermesDesktop.api<ToolsetConfig>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
|
||||
})
|
||||
}
|
||||
|
||||
export function getToolsetModels(
|
||||
name: string,
|
||||
provider?: string,
|
||||
profile?: ProfileScope
|
||||
): Promise<ToolsetModelsResponse> {
|
||||
const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : ''
|
||||
|
||||
return window.hermesDesktop.api<ToolsetModelsResponse>({
|
||||
...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<SelectToolsetProviderResponse> {
|
||||
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
|
||||
...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<ActionResponse & { key: string }> {
|
||||
return window.hermesDesktop.api<ActionResponse & { key: string }>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function getTerminalBackends(): Promise<TerminalBackendsResponse> {
|
||||
return hermesApi<TerminalBackendsResponse>({
|
||||
...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<ComputerUseStatus> {
|
||||
return hermesApi<ComputerUseStatus>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/computer-use/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function grantComputerUsePermissions(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/computer-use/permissions/grant',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user