From 164c9dfe49b2008746ed4e2b996378c8990f32ef Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 23 Jul 2026 15:57:45 -0400 Subject: [PATCH] feat(gateway): generate desktop REST contracts --- apps/desktop/src/types/hermes.ts | 1107 +++-------------------- hermes_cli/contract_types.py | 163 ++++ hermes_cli/web_contracts.py | 1061 ++++++++++++++++++++++ scripts/generate_gateway_types.py | 6 +- tests/hermes_cli/test_contract_types.py | 45 + 5 files changed, 1379 insertions(+), 1003 deletions(-) create mode 100644 hermes_cli/contract_types.py create mode 100644 hermes_cli/web_contracts.py create mode 100644 tests/hermes_cli/test_contract_types.py diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 707152e0110..d9f33ed16ef 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -1,69 +1,111 @@ import type { GatewaySessionRuntimeInfo } from '@hermes/shared/gateway-contracts' -export interface ConfigFieldSchema { - category?: string - description?: string - options?: unknown[] - type?: 'boolean' | 'list' | 'number' | 'select' | 'string' | 'text' -} +import type { + ActionResponse, + ActionStatusResponse, + AnalyticsDailyEntry, + AnalyticsModelEntry, + AnalyticsSkillEntry, + AnalyticsSkillsSummary, + AnalyticsToolEntry, + AnalyticsTotals, + AudioSpeakResponse, + AudioTranscriptionResponse, + AuxiliaryTaskAssignment, + BackendUpdateCheckResponse, + BackendUpdateCommit, + ComputerUseCheck, + ComputerUsePermissionSource, + ComputerUseStatus, + ConfigFieldSchema, + ConfigSchemaResponse, + ContextBreakdown, + ContextUsageCategory, + CronJob, + CronJobCreatePayload, + CronJobSchedule, + CronJobUpdates, + CuratorStatusResponse, + CustomEndpoint, + CustomEndpointUpdate, + CustomEndpointValidationResponse, + DebugShareResponse, + ElevenLabsVoice, + ElevenLabsVoicesResponse, + EnvVarInfo, + GatewayReadyPayload, + LogsResponse, + McpServerSummary, + MemoryProviderConfig, + MemoryProviderField, + MemoryProviderFieldKind, + MemoryProviderFieldOption, + MemoryProviderOAuthStatus, + MessagingEnvVarInfo, + MessagingHomeChannel, + MessagingPlatformInfo, + MessagingPlatformTestResponse, + MessagingPlatformUpdate, + MessagingPlatformsResponse, + MoaModelSlot, + ModelAssignmentRequest, + ModelAssignmentResponse, + ModelCapabilities, + ModelInfoResponse, + ModelOptionProvider, + ModelOptionsResponse, + ModelPricing, + OAuthPollResponse, + OAuthProvider, + OAuthProviderStatus, + OAuthProvidersResponse, + OAuthSubmitResponse, + PlatformStatus, + ProfileCreatePayload, + ProfileInfo, + ProfileSetupCommand, + ProfileSoul, + ProfilesResponse, + ProjectFolder, + ProjectInfo, + ProjectsPayload, + SessionCreateResponse, + SessionInfo, + SessionMessage, + SessionMessagesResponse, + SessionSearchResponse, + SessionSearchResult, + SkillHubInstalledEntry, + SkillHubPreview, + SkillHubResult, + SkillHubScanFinding, + SkillHubScanResult, + SkillHubSearchResponse, + SkillHubSource, + SkillHubSourcesResponse, + SkillInfo, + StaleAuxAssignment, + StarmapCluster, + StarmapEdge, + StarmapGraph, + StarmapMemoryCard, + StarmapNode, + StatusResponse, + TerminalBackendInfo, + TerminalBackendStatus, + TerminalBackendsResponse, + ToolEnvVar, + ToolProvider, + ToolProviderStatus, + ToolsetConfig, + ToolsetInfo, + ToolsetModel, + ToolsetModelsResponse, + UsageStats, + WebCapability, +} from '@hermes/shared/gateway-contracts' -export interface ConfigSchemaResponse { - category_order?: string[] - fields: Record -} - -export interface AudioTranscriptionResponse { - ok: boolean - provider?: string - transcript: string -} - -export interface AudioSpeakResponse { - ok: boolean - data_url: string - mime_type: string - provider?: string -} - -export interface ElevenLabsVoice { - label: string - name: string - voice_id: string -} - -export interface ElevenLabsVoicesResponse { - available: boolean - voices: ElevenLabsVoice[] -} - -export interface OAuthProviderStatus { - error?: string - expires_at?: null | string - has_refresh_token?: boolean - last_refresh?: null | string - logged_in: boolean - source?: null | string - source_label?: null | string - token_preview?: null | string -} - -export interface OAuthProvider { - cli_command: string - /** Shell command that clears an external provider's credentials, run in the - * embedded terminal. Null when Hermes doesn't know how to remove it. */ - disconnect_command?: null | string - disconnect_hint?: null | string - disconnectable?: boolean - docs_url: string - flow: 'device_code' | 'external' | 'pkce' - id: string - name: string - status: OAuthProviderStatus -} - -export interface OAuthProvidersResponse { - providers: OAuthProvider[] -} +export type * from '@hermes/shared/gateway-contracts' export type OAuthStartResponse = | { @@ -81,90 +123,6 @@ export type OAuthStartResponse = verification_url: string } -export interface OAuthSubmitResponse { - message?: string - ok: boolean - status: 'approved' | 'error' -} - -export interface OAuthPollResponse { - error_message?: null | string - expires_at?: null | number - session_id: string - status: 'approved' | 'denied' | 'error' | 'expired' | 'pending' -} - -export interface MemoryProviderOAuthStatus { - auth: 'apikey' | 'oauth' | null - connected: boolean - detail: string - state: 'connected' | 'error' | 'idle' | 'pending' -} - -export interface EnvVarInfo { - advanced: boolean - category: string - // True when this var is a messaging-platform credential owned by a card on - // the dedicated Messaging page. The Keys page hides these to avoid - // duplicating the richer channel-configuration UI. - channel_managed?: boolean - description: string - is_password: boolean - is_set: boolean - // Backend-derived provider grouping hints (from the unified provider catalog - // in hermes_cli/provider_catalog.py). When present, the Keys tab groups by - // this provider identity — the SAME one `hermes model` uses — instead of - // desktop-only env-var prefix guesses. Empty for non-provider env vars. - provider?: string - provider_label?: string - redacted_value: null | string - tools: string[] - url: null | string -} - -export type MemoryProviderFieldKind = 'bool' | 'json' | 'number' | 'secret' | 'select' | 'text' - -export interface MemoryProviderFieldOption { - description: string - label: string - value: string -} - -export interface MemoryProviderField { - description: string - group: string - info?: string - inline: boolean - is_set: boolean - key: string - kind: MemoryProviderFieldKind - label: string - options: MemoryProviderFieldOption[] - placeholder: string - value: string -} - -export interface MemoryProviderConfig { - docs_url: string - fields: MemoryProviderField[] - label: string - name: string -} - -export interface CustomEndpoint { - api_key_preview?: null | string - base_url: string - context_length?: null | number - discover_models: boolean - has_api_key: boolean - id: string - is_current?: boolean - model: string - models: string[] - name: string - source?: string -} - export interface CustomEndpointsResponse { current: { base_url: string @@ -176,79 +134,6 @@ export interface CustomEndpointsResponse { ok?: boolean } -export interface CustomEndpointUpdate { - api_key?: string - base_url: string - context_length?: number - discover_models?: boolean - id?: string - make_default?: boolean - model: string - name: string -} - -export interface CustomEndpointValidationResponse { - message: string - models: string[] - ok: boolean - reachable: boolean -} - -export interface MessagingEnvVarInfo { - advanced: boolean - description: string - is_password: boolean - is_set: boolean - key: string - prompt: string - redacted_value: null | string - required: boolean - url: null | string -} - -export interface MessagingHomeChannel { - chat_id: string - name: string - platform: string - thread_id?: string -} - -export interface MessagingPlatformInfo { - configured: boolean - description: string - docs_url: string - enabled: boolean - env_vars: MessagingEnvVarInfo[] - error_code?: null | string - error_message?: null | string - gateway_running: boolean - home_channel?: MessagingHomeChannel | null - id: string - name: string - state?: null | string - updated_at?: null | string -} - -export interface MessagingPlatformsResponse { - platforms: MessagingPlatformInfo[] -} - -export interface MessagingPlatformUpdate { - clear_env?: string[] - enabled?: boolean - env?: Record -} - -export interface MessagingPlatformTestResponse { - message: string - ok: boolean - state?: null | string -} - -export interface GatewayReadyPayload { - skin?: unknown -} - export interface HermesConfig { agent?: { reasoning_effort?: string @@ -279,74 +164,6 @@ export interface HermesConfig { export type HermesConfigRecord = Record -export interface ModelInfoResponse { - auto_context_length?: number - capabilities?: Record - config_context_length?: number - effective_context_length?: number - model: string - provider: string -} - -export interface ModelPricing { - /** Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown. */ - input: string - /** Formatted $/Mtok output price. */ - output: string - /** Formatted $/Mtok cached-input price, or null when the model has none. */ - cache: string | null - /** True when the model costs nothing (free tier eligible). */ - free: boolean - /** Sale: rounded percent off list when gateway sends pricing.original. */ - discount_percent?: number - /** Sale: formatted pre-discount input $/Mtok ("was"). */ - was_input?: string - /** Sale: formatted pre-discount output $/Mtok ("was"). */ - was_output?: string -} - -export interface ModelOptionProvider { - is_current?: boolean - models?: string[] - name: string - slug: string - total_models?: number - warning?: string - /** True when the provider has usable credentials. False for canonical - * providers surfaced by `include_unconfigured` that the user hasn't set up - * yet — render these with a setup affordance instead of hiding them. */ - authenticated?: boolean - /** Auth flow for an unconfigured provider: "api_key" can be activated inline - * by pasting `key_env`; anything else (oauth_*, external, aws_sdk, …) needs - * the `hermes model` CLI / onboarding OAuth flow. */ - auth_type?: string - /** Env var to paste an API key into, for unconfigured `api_key` providers. */ - key_env?: string - /** True for providers defined via the user's `providers:` config block. */ - is_user_defined?: boolean - /** Per-model pricing keyed by model id (present when the picker requested - * pricing and the provider supports live pricing). */ - pricing?: Record - /** Nous only: whether the current account is on the free tier. */ - free_tier?: boolean - /** Nous only: paid models a free-tier user cannot select (shown disabled). */ - unavailable_models?: string[] - /** Per-model option support, keyed by model id (present when the picker - * requested capabilities). Lets the UI gate fast/reasoning controls. */ - capabilities?: Record -} - -export interface ModelCapabilities { - fast: boolean - reasoning: boolean -} - -export interface ModelOptionsResponse { - model?: string - provider?: string - providers?: ModelOptionProvider[] -} - export interface PaginatedSessions { limit: number offset: number @@ -369,89 +186,10 @@ export interface RpcEvent { type: string } -export interface SessionCreateResponse { - info?: SessionRuntimeInfo - message_count?: number - messages?: SessionMessage[] - session_id: string - stored_session_id?: string -} - -export interface SessionInfo { - archived?: boolean - cwd?: null | string - /** Git branch checked out in {@link cwd} when the session started/resumed. - * The sidebar groups main-checkout sessions by this so feature-branch work - * doesn't collapse under a single directory-named "main" row. Null for - * non-git workspaces and sessions created before branch capture landed. */ - git_branch?: null | string - /** Git repo root that owns {@link cwd} — the authoritative project key, - * resolved server-side at cwd-set (and backfilled for history). The sidebar - * groups by this instead of probing git in the GUI. Null for non-git - * workspaces and not-yet-backfilled rows. */ - git_repo_root?: null | string - ended_at: null | number - id: string - /** Original root id of a compression chain, when this entry is a projected - * continuation tip. Stable across compressions — used as the durable id for - * pins so a pinned conversation survives auto-compression. */ - _lineage_root_id?: null | string - input_tokens: number - is_active: boolean - last_active: number - message_count: number - model: null | string - output_tokens: number - /** Parent conversation when this row is a /branch fork. */ - parent_session_id?: null | string - preview: null | string - source: null | string - started_at: number - title: null | string - tool_call_count: number - /** Origin platform when this session was handed off from a messaging - * platform (e.g. a Telegram thread continued in the desktop app). The live - * {@link source} becomes local (tui/desktop) after a handoff, so the origin - * is preserved here to surface the platform badge on the row. */ - handoff_platform?: null | string - /** Handoff lifecycle: 'pending' | 'in_progress' | 'completed' | 'failed'. */ - handoff_state?: null | string - handoff_error?: null | string - /** Owning profile name, set by the cross-profile aggregator - * (`/api/profiles/sessions`). Absent on legacy single-profile responses, - * which the UI treats as the default profile. */ - profile?: string - /** True when {@link profile} is the default profile. */ - is_default_profile?: boolean -} - export type TimelineDisplayMetadata = | { model: string; provider?: string } | { delegation_id: string; task_count: number; completed_count?: number; failed_count?: number; duration_seconds?: number } -export interface SessionMessage { - codex_reasoning_items?: unknown - content: unknown - context?: unknown - name?: string - reasoning?: null | string - reasoning_content?: null | string - reasoning_details?: unknown - display_kind?: 'async_delegation_complete' | 'hidden' | 'model_switch' | string - display_metadata?: TimelineDisplayMetadata - role: 'assistant' | 'system' | 'tool' | 'user' - text?: unknown - timestamp?: number - tool_call_id?: null | string - tool_calls?: unknown - tool_name?: string -} - -export interface SessionMessagesResponse { - messages: SessionMessage[] - session_id: string -} - export interface SessionResumeResponse { inflight?: null | { assistant?: string @@ -474,95 +212,6 @@ export interface SessionResumeResponse { export type SessionRuntimeInfo = GatewaySessionRuntimeInfo -export interface UsageStats { - calls: number - context_max?: number - context_percent?: number - context_used?: number - cost_usd?: number - input: number - output: number - total: number -} - -/** One graph node in the star map (learned skill or memory chunk). */ -export interface StarmapNode { - id: string - label: string - kind: 'memory' | 'skill' - memorySource?: 'memory' | 'profile' - timestamp?: null | number - category: string - useCount: number - state: string - createdBy: null | string - pinned: boolean -} - -/** A declared `related_skills` link; both endpoints are guaranteed to be nodes. */ -export interface StarmapEdge { - source: string - target: string -} - -export interface StarmapCluster { - category: string - count: number -} - -/** Freeform memory rendered as a card — never a graph node. */ -export interface StarmapMemoryCard { - source: 'memory' | 'profile' - timestamp?: null | number - title: string - body: string -} - -export interface StarmapGraph { - nodes: StarmapNode[] - edges: StarmapEdge[] - clusters: StarmapCluster[] - memory: StarmapMemoryCard[] - stats: Record -} - -export interface ContextUsageCategory { - color: string - id: string - label: string - tokens: number -} - -export interface ContextBreakdown { - categories: ContextUsageCategory[] - context_max: number - context_percent: number - context_used: number - estimated_total: number - model?: string -} - -export interface AnalyticsDailyEntry { - actual_cost: number - api_calls: number - cache_read_tokens: number - day: string - estimated_cost: number - input_tokens: number - output_tokens: number - reasoning_tokens: number - sessions: number -} - -export interface AnalyticsModelEntry { - api_calls: number - estimated_cost: number - input_tokens: number - model: string - output_tokens: number - sessions: number -} - export interface AnalyticsResponse { by_model: AnalyticsModelEntry[] daily: AnalyticsDailyEntry[] @@ -576,407 +225,11 @@ export interface AnalyticsResponse { totals: AnalyticsTotals } -export interface AnalyticsToolEntry { - count: number - percentage: number - tool: string -} - -export interface AnalyticsSkillEntry { - last_used_at: null | number - manage_count: number - percentage: number - skill: string - total_count: number - view_count: number -} - -export interface AnalyticsSkillsSummary { - distinct_skills_used: number - total_skill_actions: number - total_skill_edits: number - total_skill_loads: number -} - -export interface AnalyticsTotals { - total_actual_cost: number - total_api_calls: null | number - total_cache_read: null | number - total_estimated_cost: number - total_input: null | number - total_output: null | number - total_reasoning: null | number - total_sessions: number -} - -export interface CronJob { - deliver?: null | string - enabled: boolean - id: string - last_error?: null | string - last_run_at?: null | string - model?: null | string - name?: null | string - next_run_at?: null | string - no_agent?: boolean - prompt?: null | string - provider?: null | string - schedule?: CronJobSchedule - schedule_display?: null | string - script?: null | string - state?: null | string -} - -export interface CronJobCreatePayload { - deliver?: string - model?: string - name?: string - prompt: string - provider?: string - schedule: string -} - -export interface CronJobSchedule { - display?: string - expr?: string - kind?: string -} - -export interface CronJobUpdates { - deliver?: string - enabled?: boolean - model?: null | string - name?: string - prompt?: string - provider?: null | string - schedule?: string -} - -export interface ProfileCreatePayload { - clone_all?: boolean - clone_from?: null | string - clone_from_default?: boolean - name: string - no_skills?: boolean -} - -export interface ProfileInfo { - has_env: boolean - is_default: boolean - model: null | string - name: string - path: string - provider: null | string - skill_count: number -} - -export interface ProfileSetupCommand { - command: string -} - -// ── Projects ─────────────────────────────────────────────────────────────── -// A first-class, per-profile, human-named workspace spanning one or more -// folders. Mirrors hermes_cli/projects_db.Project.to_dict(). -export interface ProjectFolder { - path: string - label: null | string - is_primary: boolean - added_at: number -} - -export interface ProjectInfo { - id: string - slug: string - name: string - description: null | string - icon: null | string - color: null | string - board_slug: null | string - primary_path: null | string - archived: boolean - created_at: number - folders: ProjectFolder[] -} - -export interface ProjectsPayload { - projects: ProjectInfo[] - active_id: null | string -} - -export interface ProfileSoul { - content: string - exists: boolean -} - -export interface ProfilesResponse { - profiles: ProfileInfo[] -} - -export interface SkillInfo { - category: string - description: string - enabled: boolean - name: string - /** Total observed activity (use + view + patch). Absent on older backends. */ - usage?: number - /** 'agent' = learned/local (editable), 'bundled' = ships with Hermes, 'hub' = installed. */ - provenance?: 'agent' | 'bundled' | 'hub' -} - -export interface ToolsetInfo { - configured: boolean - description: string - enabled: boolean - label: string - name: string - tools: string[] -} - -export interface ToolEnvVar { - key: string - prompt: string - url: string | null - default: string | null - is_set: boolean -} - -/** Server-computed readiness for a provider picker row. Absent on older - * backends that predate the truthful-readiness endpoint. */ -export type ToolProviderStatus = 'ready' | 'needs_setup' | 'needs_auth' | 'needs_keys' - -export interface ToolProvider { - name: string - badge: string - tag: string - env_vars: ToolEnvVar[] - post_setup: string | null - requires_nous_auth: boolean - /** True when this is the provider currently written to config (mirrors the - * CLI `hermes tools` active-provider detection). */ - is_active: boolean - /** Honest readiness computed server-side (keys ∧ Nous entitlement ∧ - * post-setup install state). Optional for older backends. */ - status?: ToolProviderStatus - /** Web toolset only: the backend key written to web.*backend config - * (e.g. 'searxng'). Absent on other toolsets and older backends. */ - web_backend?: string - /** TTS toolset only: the provider key written to tts.provider when this row - * is selected (e.g. 'openai'). Doubles as the config section that holds the - * provider's voice/model settings (tts..*). Absent on other toolsets - * and older backends. */ - tts_provider?: string - /** Web toolset only: capabilities this backend can serve. Search-only - * providers (ddgs, brave-free) report ['search']. */ - capabilities?: WebCapability[] -} - -/** A web toolset capability — the runtime dispatches web_search and - * web_extract to independently configurable backends. */ -export type WebCapability = 'search' | 'extract' - -export interface ToolsetConfig { - name: string - has_category: boolean - providers: ToolProvider[] - /** Name of the currently active provider, or null if none is configured. */ - active_provider: string | null - /** Web toolset only: backend the web_search tool resolves to right now - * (web.search_backend → web.backend → credential auto-detect). */ - active_search_backend?: string | null - /** Web toolset only: backend the web_extract tool resolves to right now. */ - active_extract_backend?: string | null -} - -/** Health status of a terminal execution backend row. - * - * `ready` — usable now; `needs_setup` — selectable but missing a dependency - * or credential (detail says which); `unavailable` — the probe itself failed. */ -export type TerminalBackendStatus = 'ready' | 'needs_setup' | 'unavailable' - -/** One row from `GET /api/tools/terminal/backends`. */ -export interface TerminalBackendInfo { - name: string - label: string - description: string - /** True when this backend is the current `terminal.backend` config value. */ - active: boolean - status: TerminalBackendStatus - /** Setup guidance / probe detail for non-ready rows (empty when ready). */ - detail: string -} - -/** Shape of `GET /api/tools/terminal/backends`. */ -export interface TerminalBackendsResponse { - active: string - backends: TerminalBackendInfo[] -} - -/** One model row from a toolset backend's catalog (image/video gen). */ -export interface ToolsetModel { - id: string - display: string - speed: string - strengths: string - price: string -} - -/** Shape of `GET /api/tools/toolsets/{name}/models`. */ -export interface ToolsetModelsResponse { - name: string - has_models: boolean - provider?: string | null - plugin?: string | null - models: ToolsetModel[] - current: string | null - default: string | null -} - -/** Shape of `GET /api/tools/computer-use/status`. - * - * cua-driver runs on macOS, Windows, and Linux. `ready` is the single OS-aware - * readiness signal: on macOS both TCC grants (Accessibility + Screen - * Recording, which attach to cua-driver's own `com.trycua.driver` identity, - * not Hermes); elsewhere, driver health from `cua-driver doctor`. `null` - * means unknown (binary missing / probe failed). */ -export interface ComputerUsePermissionSource { - attribution?: string - executable?: string - note?: string - pid?: number - responsible_ppid?: number -} - -export interface ComputerUseCheck { - label: string - status: string - message: string -} - -export interface ComputerUseStatus { - /** `sys.platform`: "darwin" | "win32" | "linux" | ... */ - platform: string - /** cua-driver has a runtime backend for this platform. */ - platform_supported: boolean - /** cua-driver binary resolved on PATH. */ - installed: boolean - /** e.g. "cua-driver 0.5.1", or null when unknown. */ - version: string | null - /** Unified readiness — both TCC grants (macOS) or driver health (else). */ - ready: boolean | null - /** Whether a permission grant flow exists (macOS-only TCC). */ - can_grant: boolean - /** Cross-platform `cua-driver doctor` probes. */ - checks: ComputerUseCheck[] - /** macOS TCC detail — `null` off macOS or when unknown. */ - accessibility: boolean | null - screen_recording: boolean | null - screen_recording_capturable: boolean | null - source: ComputerUsePermissionSource | null - /** Populated when the status probe itself failed. */ - error: string | null -} - -export interface SessionSearchResult { - /** Lineage root of the matched conversation. Stable across compression and - * used as the durable pin id; falls back to session_id when absent. */ - lineage_root?: string | null - model: string | null - role: string | null - /** Live compression tip of the matched conversation — resume by this id. */ - session_id: string - session_started: number | null - snippet: string - source: string | null -} - -export interface SessionSearchResponse { - results: SessionSearchResult[] -} - -export interface LogsResponse { - file: string - lines: string[] -} - -export interface PlatformStatus { - error_code?: string - error_message?: string - state: string - updated_at: string -} - -export interface StatusResponse { - active_sessions: number - config_path: string - config_version: number - env_path: string - gateway_exit_reason: string | null - gateway_health_url: string | null - gateway_pid: number | null - gateway_platforms: Record - gateway_running: boolean - gateway_state: string | null - gateway_updated_at: string | null - hermes_home: string - latest_config_version: number - release_date: string - version: string -} - -export interface ActionResponse { - name: string - ok: boolean - pid: number -} - -export interface ActionStatusResponse { - exit_code: number | null - lines: string[] - name: string - pid: number | null - running: boolean -} - -export interface BackendUpdateCommit { - sha: string - summary: string - author: string - at: number -} - -/** Shape of `GET /api/hermes/update/check` — the backend's own update state. - * Used by the desktop's remote update overlay so the backend version (not the - * Electron client clone) drives "what's changed + Install" in remote mode. */ -export interface BackendUpdateCheckResponse { - install_method: string - current_version: string - behind: number | null - update_available: boolean - can_apply: boolean - update_command: string | null - message: string | null - commits?: BackendUpdateCommit[] -} - -export interface AuxiliaryTaskAssignment { - base_url: string - model: string - provider: string - task: string -} - export interface AuxiliaryModelsResponse { main: { model: string; provider: string } tasks: AuxiliaryTaskAssignment[] } -export interface MoaModelSlot { - provider: string - model: string - /** Optional per-slot reasoning effort — round-tripped, not edited here. */ - reasoning_effort?: string -} - export interface MoaConfigResponse { default_preset: string active_preset: string @@ -1003,117 +256,6 @@ export interface MoaConfigResponse { reference_temperature: number } -export interface ModelAssignmentRequest { - /** Optional API key for a custom/local endpoint. Persisted to model.api_key - * (where the runtime reads it) for self-hosted endpoints that require auth. - * Only honored for custom/local providers on the main slot. */ - api_key?: string - /** OpenAI-compatible endpoint URL. Only honored for custom/local providers - * on the main slot — wires a self-hosted endpoint into runtime resolution. */ - base_url?: string - model: string - provider: string - scope: 'main' | 'auxiliary' - task?: string -} - -/** An auxiliary task still pinned to a provider that differs from the - * newly-selected main provider after a main-model switch. */ -export interface StaleAuxAssignment { - task: string - provider: string - model: string -} - -/** One skill-hub source (official index, GitHub, skills.sh, …) as reported by - * `GET /api/skills/hub/sources`. */ -export interface SkillHubSource { - id: string - label: string - available?: boolean - rate_limited?: boolean - // False when the centralized index already covers this source, so the UI's - // per-source search fan-out skips it (avoids redundant external API calls). - searchable?: boolean -} - -/** A searchable/installable hub skill from `GET /api/skills/hub/search`. */ -export interface SkillHubResult { - name: string - description: string - source: string - identifier: string - trust_level: string - repo: string | null - tags: string[] -} - -export interface SkillHubInstalledEntry { - name: string | null - trust_level: string | null - scan_verdict: string | null -} - -export interface SkillHubSourcesResponse { - sources: SkillHubSource[] - index_available: boolean - featured: SkillHubResult[] - installed: Record -} - -export interface SkillHubSearchResponse { - results: SkillHubResult[] - source_counts: Record - timed_out: string[] - installed: Record -} - -/** `GET /api/skills/hub/preview` — SKILL.md + manifest without installing. */ -export interface SkillHubPreview { - name: string - description: string - source: string - identifier: string - trust_level: string - repo: string | null - tags: string[] - skill_md: string - files: string[] -} - -export interface SkillHubScanFinding { - severity: string - category: string - file: string - line: number | null - description: string -} - -/** `GET /api/skills/hub/scan` — install-time security scan verdict. */ -export interface SkillHubScanResult { - name: string - identifier: string - source: string - trust_level: string - verdict: string - summary: string - policy: 'allow' | 'ask' | 'block' - policy_reason: string | null - findings: SkillHubScanFinding[] - severity_counts: Record -} - -/** One configured MCP server row from `GET /api/mcp/servers`. */ -export interface McpServerSummary { - name: string - transport: string - command: string | null - args: string[] - url: string | null - enabled: boolean - tools: string[] | null -} - export interface McpServerTestResponse { ok: boolean error?: string @@ -1152,42 +294,3 @@ export interface MemoryStatusResponse { providers: { name: string; description: string; configured: boolean }[] builtin_files: { memory: number; user: number } } - -/** `GET /api/curator` — background skill-curator status. */ -export interface CuratorStatusResponse { - enabled: boolean - paused: boolean - interval_hours: number | null - last_run_at: string | null - min_idle_hours: number | null - stale_after_days: number | null - archive_after_days: number | null -} - -/** `POST /api/ops/debug-share` — shareable diagnostics upload result. */ -export interface DebugShareResponse { - ok: boolean - urls: Record - failures: Record - redacted: boolean - auto_delete_seconds: number | null -} - -export interface ModelAssignmentResponse { - /** Persisted endpoint URL for custom/local providers (echoed back). */ - base_url?: string - /** Toolset keys auto-routed through the Nous Tool Gateway as a result of - * switching the main provider to Nous. Empty unless provider === 'nous' - * and the user is a paid subscriber with unconfigured tools. */ - gateway_tools?: string[] - model?: string - ok: boolean - provider?: string - reset?: boolean - scope?: string - /** Auxiliary slots still pinned to a different provider than the new main. - * Switching main never clears aux pins; this lets the UI warn the user - * their helper tasks aren't following the switch. Only set on scope:'main'. */ - stale_aux?: StaleAuxAssignment[] - tasks?: string[] -} diff --git a/hermes_cli/contract_types.py b/hermes_cli/contract_types.py new file mode 100644 index 00000000000..ce4e4e09dcd --- /dev/null +++ b/hermes_cli/contract_types.py @@ -0,0 +1,163 @@ +"""ts-type extensions for documented Python gateway contracts. + +Python keeps PEP 257 attribute docstrings in source ASTs but discards them at +runtime. This module restores those docs while `ts-type` renders our stdlib +``TypedDict`` contracts, making Python the canonical source for both wire +shape and TypeScript documentation. +""" + +from __future__ import annotations + +import ast +from functools import lru_cache +import inspect +from pathlib import Path +from typing import Any, get_args, get_origin, get_type_hints, NotRequired, Required + +import ts_type as ts + + +class OpaqueValue: + """A JSON value whose structure is intentionally not part of the contract.""" + + +def _unwrap_typed_dict_marker(value: Any) -> Any: + """Strip Python-only requiredness markers before ts-type renders a field.""" + if get_origin(value) in {NotRequired, Required}: + return get_args(value)[0] + return value + + +@lru_cache(maxsize=None) +def _typed_dict_docs(contract: type) -> tuple[str | None, dict[str, str]]: + """Return the PEP 257 class and attribute docs for a contract source class.""" + source_path = inspect.getsourcefile(contract) + if source_path is None: + return None, {} + + module = ast.parse(Path(source_path).read_text(encoding="utf-8")) + node: ast.ClassDef | ast.Module = module + for name in contract.__qualname__.split("."): + if name == "": + continue + child = next( + ( + candidate + for candidate in node.body + if isinstance(candidate, ast.ClassDef) and candidate.name == name + ), + None, + ) + if child is None: + return None, {} + node = child + + class_doc = ast.get_docstring(node) + field_docs: dict[str, str] = {} + for index, child in enumerate(node.body[:-1]): + next_child = node.body[index + 1] + if not ( + isinstance(child, ast.AnnAssign) + and isinstance(child.target, ast.Name) + and isinstance(next_child, ast.Expr) + and isinstance(next_child.value, ast.Constant) + and isinstance(next_child.value.value, str) + ): + continue + field_docs[child.target.id] = inspect.cleandoc(next_child.value.value) + return class_doc, field_docs + + +def _render_jsdoc(doc: str, indent: str = "") -> str: + """Render a Python docstring as an escaped TypeScript JSDoc block.""" + lines = inspect.cleandoc(doc).replace("*/", "*\\/").splitlines() or [""] + if len(lines) == 1: + return f"{indent}/** {lines[0]} */" + return "\n".join( + [f"{indent}/**", *[f"{indent} * {line}" for line in lines], f"{indent} */"] + ) + + +class DocumentedObject(ts.Object): + """A TypeScript object node which retains PEP 257 field docs.""" + + def __init__( + self, + *args: Any, + class_doc: str | None, + field_docs: dict[str, str], + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.class_doc = class_doc + self.field_docs = field_docs + + def render(self, context: ts.RenderContext) -> str: + child_context = context.clone(indent_level=context.indent_level + 1) + fields: list[str] = [] + for key, value in self.attrs.items(): + if doc := self.field_docs.get(key): + fields.append(_render_jsdoc(doc, child_context.indent)) + fields.append( + "".join( + [ + child_context.indent, + "readonly " if key in self.readonly else "", + f'"{key}"', + "?" if key in self.omissible else "", + ": ", + value.render(child_context), + ";", + ] + ) + ) + return "\n".join(["{", *fields, f"{context.indent}}}"]) + + +class DocumentedContractBuilder(ts.NodeBuilder): + """Render ``TypedDict`` contracts with PEP 257 class and field docs.""" + + def handle_unknown_type(self, value: Any) -> ts.TypeNode: + if value is OpaqueValue: + return ts.Unknown() + return super().handle_unknown_type(value) + + def typeddict_to_node(self, contract: type) -> ts.TypeNode: + class_doc, field_docs = _typed_dict_docs(contract) + annotations = get_type_hints(contract, include_extras=True) + omissible = contract.__optional_keys__ | { + key + for key, value in annotations.items() + if get_origin(value) is NotRequired + } + return self.define_ref_node( + contract, + lambda: DocumentedObject( + attrs={ + key: self.type_to_node(_unwrap_typed_dict_marker(value)) + for key, value in annotations.items() + }, + omissible=omissible, + class_doc=class_doc, + field_docs=field_docs, + ), + ) + + def render(self, refs_to_export: set[str] | None = None) -> str: + context = ts.RenderContext(self.definitions) + to_export = refs_to_export or set(self.definitions) + names = [name for name in self.definitions if name in to_export] + [ + name for name in self.definitions if name not in to_export + ] + + def render(name: str) -> str: + node = context.definitions[name] + prefix = "" + if name in to_export and isinstance(node, ts.Reference): + resolved = context.resolve_ref(node) + if isinstance(resolved, DocumentedObject) and resolved.class_doc: + prefix = _render_jsdoc(resolved.class_doc) + "\n" + export = "export " if name in to_export else "" + return f"{prefix}{export}type {ts.Reference(name).render(context)} = {node.render(context)};" + + return "\n\n".join(render(name) for name in names) diff --git a/hermes_cli/web_contracts.py b/hermes_cli/web_contracts.py new file mode 100644 index 00000000000..76599849ceb --- /dev/null +++ b/hermes_cli/web_contracts.py @@ -0,0 +1,1061 @@ +"""REST payload contracts for the desktop/dashboard API. + +These PEP 257-documented TypedDicts are the source of truth for generated +TypeScript gateway contracts. They intentionally describe plain JSON values only. +""" + +from __future__ import annotations + +from typing import Literal, NotRequired, TypeAlias, TypedDict + +from hermes_cli.contract_types import OpaqueValue +from tui_gateway.contracts import GatewaySessionRuntimeInfo + +class ConfigFieldSchema(TypedDict): + category: NotRequired[str] + description: NotRequired[str] + options: NotRequired[list[OpaqueValue]] + type: NotRequired[Literal['boolean'] | Literal['list'] | Literal['number'] | Literal['select'] | Literal['string'] | Literal['text']] + +class ConfigSchemaResponse(TypedDict): + category_order: NotRequired[list[str]] + fields: dict[str, ConfigFieldSchema] + +class AudioTranscriptionResponse(TypedDict): + ok: bool + provider: NotRequired[str] + transcript: str + +class AudioSpeakResponse(TypedDict): + ok: bool + data_url: str + mime_type: str + provider: NotRequired[str] + +class ElevenLabsVoice(TypedDict): + label: str + name: str + voice_id: str + +class ElevenLabsVoicesResponse(TypedDict): + available: bool + voices: list[ElevenLabsVoice] + +class OAuthProviderStatus(TypedDict): + error: NotRequired[str] + expires_at: NotRequired[None | str] + has_refresh_token: NotRequired[bool] + last_refresh: NotRequired[None | str] + logged_in: bool + source: NotRequired[None | str] + source_label: NotRequired[None | str] + token_preview: NotRequired[None | str] + +class OAuthProvider(TypedDict): + cli_command: str + disconnect_command: NotRequired[None | str] + """Shell command that clears an external provider's credentials, run in the + embedded terminal. Null when Hermes doesn't know how to remove it.""" + disconnect_hint: NotRequired[None | str] + disconnectable: NotRequired[bool] + docs_url: str + flow: Literal['device_code'] | Literal['external'] | Literal['pkce'] + id: str + name: str + status: OAuthProviderStatus + +class OAuthProvidersResponse(TypedDict): + providers: list[OAuthProvider] + +class OAuthSubmitResponse(TypedDict): + message: NotRequired[str] + ok: bool + status: Literal['approved'] | Literal['error'] + +class OAuthPollResponse(TypedDict): + error_message: NotRequired[None | str] + expires_at: NotRequired[None | float] + session_id: str + status: Literal['approved'] | Literal['denied'] | Literal['error'] | Literal['expired'] | Literal['pending'] + +class MemoryProviderOAuthStatus(TypedDict): + auth: Literal['apikey'] | Literal['oauth'] | None + connected: bool + detail: str + state: Literal['connected'] | Literal['error'] | Literal['idle'] | Literal['pending'] + +class EnvVarInfo(TypedDict): + advanced: bool + category: str + channel_managed: NotRequired[bool] + description: str + is_password: bool + is_set: bool + provider: NotRequired[str] + provider_label: NotRequired[str] + redacted_value: None | str + tools: list[str] + url: None | str + +MemoryProviderFieldKind: TypeAlias = Literal['bool'] | Literal['json'] | Literal['number'] | Literal['secret'] | Literal['select'] | Literal['text'] + +class MemoryProviderFieldOption(TypedDict): + description: str + label: str + value: str + +class MemoryProviderField(TypedDict): + description: str + group: str + info: NotRequired[str] + inline: bool + is_set: bool + key: str + kind: MemoryProviderFieldKind + label: str + options: list[MemoryProviderFieldOption] + placeholder: str + value: str + +class MemoryProviderConfig(TypedDict): + docs_url: str + fields: list[MemoryProviderField] + label: str + name: str + +class CustomEndpoint(TypedDict): + api_key_preview: NotRequired[None | str] + base_url: str + context_length: NotRequired[None | float] + discover_models: bool + has_api_key: bool + id: str + is_current: NotRequired[bool] + model: str + models: list[str] + name: str + source: NotRequired[str] + +class CustomEndpointUpdate(TypedDict): + api_key: NotRequired[str] + base_url: str + context_length: NotRequired[float] + discover_models: NotRequired[bool] + id: NotRequired[str] + make_default: NotRequired[bool] + model: str + name: str + +class CustomEndpointValidationResponse(TypedDict): + message: str + models: list[str] + ok: bool + reachable: bool + +class MessagingEnvVarInfo(TypedDict): + advanced: bool + description: str + is_password: bool + is_set: bool + key: str + prompt: str + redacted_value: None | str + required: bool + url: None | str + +class MessagingHomeChannel(TypedDict): + chat_id: str + name: str + platform: str + thread_id: NotRequired[str] + +class MessagingPlatformInfo(TypedDict): + configured: bool + description: str + docs_url: str + enabled: bool + env_vars: list[MessagingEnvVarInfo] + error_code: NotRequired[None | str] + error_message: NotRequired[None | str] + gateway_running: bool + home_channel: NotRequired[MessagingHomeChannel | None] + id: str + name: str + state: NotRequired[None | str] + updated_at: NotRequired[None | str] + +class MessagingPlatformsResponse(TypedDict): + platforms: list[MessagingPlatformInfo] + +class MessagingPlatformUpdate(TypedDict): + clear_env: NotRequired[list[str]] + enabled: NotRequired[bool] + env: NotRequired[dict[str, str]] + +class MessagingPlatformTestResponse(TypedDict): + message: str + ok: bool + state: NotRequired[None | str] + +class GatewayReadyPayload(TypedDict): + skin: NotRequired[OpaqueValue] + +class ModelInfoResponse(TypedDict): + auto_context_length: NotRequired[float] + capabilities: NotRequired[dict[str, OpaqueValue]] + config_context_length: NotRequired[float] + effective_context_length: NotRequired[float] + model: str + provider: str + +class ModelPricing(TypedDict): + input: str + """Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown.""" + output: str + """Formatted $/Mtok output price.""" + cache: str | None + """Formatted $/Mtok cached-input price, or null when the model has none.""" + free: bool + """True when the model costs nothing (free tier eligible).""" + discount_percent: NotRequired[float] + """Sale: rounded percent off list when gateway sends pricing.original.""" + was_input: NotRequired[str] + """Sale: formatted pre-discount input $/Mtok ("was").""" + was_output: NotRequired[str] + """Sale: formatted pre-discount output $/Mtok ("was").""" + +class ModelOptionProvider(TypedDict): + is_current: NotRequired[bool] + models: NotRequired[list[str]] + name: str + slug: str + total_models: NotRequired[float] + warning: NotRequired[str] + authenticated: NotRequired[bool] + """True when the provider has usable credentials. False for canonical + providers surfaced by `include_unconfigured` that the user hasn't set up + yet — render these with a setup affordance instead of hiding them.""" + auth_type: NotRequired[str] + """Auth flow for an unconfigured provider: "api_key" can be activated inline + by pasting `key_env`; anything else (oauth_*, external, aws_sdk, …) needs + the `hermes model` CLI / onboarding OAuth flow.""" + key_env: NotRequired[str] + """Env var to paste an API key into, for unconfigured `api_key` providers.""" + is_user_defined: NotRequired[bool] + """True for providers defined via the user's `providers:` config block.""" + pricing: NotRequired[dict[str, ModelPricing]] + """Per-model pricing keyed by model id (present when the picker requested + pricing and the provider supports live pricing).""" + free_tier: NotRequired[bool] + """Nous only: whether the current account is on the free tier.""" + unavailable_models: NotRequired[list[str]] + """Nous only: paid models a free-tier user cannot select (shown disabled).""" + capabilities: NotRequired[dict[str, ModelCapabilities]] + """Per-model option support, keyed by model id (present when the picker + requested capabilities). Lets the UI gate fast/reasoning controls.""" + +class ModelCapabilities(TypedDict): + fast: bool + reasoning: bool + +class ModelOptionsResponse(TypedDict): + model: NotRequired[str] + provider: NotRequired[str] + providers: NotRequired[list[ModelOptionProvider]] + +class SessionCreateResponse(TypedDict): + info: NotRequired[GatewaySessionRuntimeInfo] + message_count: NotRequired[float] + messages: NotRequired[list[SessionMessage]] + session_id: str + stored_session_id: NotRequired[str] + +class SessionInfo(TypedDict): + archived: NotRequired[bool] + cwd: NotRequired[None | str] + git_branch: NotRequired[None | str] + """Git branch checked out in {@link cwd} when the session started/resumed. + The sidebar groups main-checkout sessions by this so feature-branch work + doesn't collapse under a single directory-named "main" row. Null for + non-git workspaces and sessions created before branch capture landed.""" + git_repo_root: NotRequired[None | str] + """Git repo root that owns {@link cwd} — the authoritative project key, + resolved server-side at cwd-set (and backfilled for history). The sidebar + groups by this instead of probing git in the GUI. Null for non-git + workspaces and not-yet-backfilled rows.""" + ended_at: None | float + id: str + _lineage_root_id: NotRequired[None | str] + """Original root id of a compression chain, when this entry is a projected + continuation tip. Stable across compressions — used as the durable id for + pins so a pinned conversation survives auto-compression.""" + input_tokens: float + is_active: bool + last_active: float + message_count: float + model: None | str + output_tokens: float + parent_session_id: NotRequired[None | str] + """Parent conversation when this row is a /branch fork.""" + preview: None | str + source: None | str + started_at: float + title: None | str + tool_call_count: float + handoff_platform: NotRequired[None | str] + """Origin platform when this session was handed off from a messaging + platform (e.g. a Telegram thread continued in the desktop app). The live + {@link source} becomes local (tui/desktop) after a handoff, so the origin + is preserved here to surface the platform badge on the row.""" + handoff_state: NotRequired[None | str] + """Handoff lifecycle: 'pending' | 'in_progress' | 'completed' | 'failed'.""" + handoff_error: NotRequired[None | str] + profile: NotRequired[str] + """Owning profile name, set by the cross-profile aggregator + (`/api/profiles/sessions`). Absent on legacy single-profile responses, + which the UI treats as the default profile.""" + is_default_profile: NotRequired[bool] + """True when {@link profile} is the default profile.""" + +class TimelineModelDisplayMetadata(TypedDict): + """Model-switch metadata attached to a timeline display event.""" + + model: str + provider: NotRequired[str] + + +class TimelineDelegationDisplayMetadata(TypedDict): + """Delegated-task completion metadata attached to a timeline event.""" + + delegation_id: str + task_count: float + completed_count: NotRequired[float] + failed_count: NotRequired[float] + duration_seconds: NotRequired[float] + + +TimelineDisplayMetadata: TypeAlias = ( + TimelineModelDisplayMetadata | TimelineDelegationDisplayMetadata +) + + +class SessionMessage(TypedDict): + codex_reasoning_items: NotRequired[OpaqueValue] + content: OpaqueValue + context: NotRequired[OpaqueValue] + name: NotRequired[str] + reasoning: NotRequired[None | str] + reasoning_content: NotRequired[None | str] + reasoning_details: NotRequired[OpaqueValue] + display_kind: NotRequired[Literal['async_delegation_complete'] | Literal['hidden'] | Literal['model_switch'] | str] + display_metadata: NotRequired[TimelineDisplayMetadata] + role: Literal['assistant'] | Literal['system'] | Literal['tool'] | Literal['user'] + text: NotRequired[OpaqueValue] + timestamp: NotRequired[float] + tool_call_id: NotRequired[None | str] + tool_calls: NotRequired[OpaqueValue] + tool_name: NotRequired[str] + +class SessionMessagesResponse(TypedDict): + messages: list[SessionMessage] + session_id: str + +class UsageStats(TypedDict): + calls: float + context_max: NotRequired[float] + context_percent: NotRequired[float] + context_used: NotRequired[float] + cost_usd: NotRequired[float] + input: float + output: float + total: float + +class StarmapNode(TypedDict): + """One graph node in the star map (learned skill or memory chunk).""" + + id: str + label: str + kind: Literal['memory'] | Literal['skill'] + memorySource: NotRequired[Literal['memory'] | Literal['profile']] + timestamp: NotRequired[None | float] + category: str + useCount: float + state: str + createdBy: None | str + pinned: bool + +class StarmapEdge(TypedDict): + """A declared `related_skills` link; both endpoints are guaranteed to be nodes.""" + + source: str + target: str + +class StarmapCluster(TypedDict): + category: str + count: float + +class StarmapMemoryCard(TypedDict): + """Freeform memory rendered as a card — never a graph node.""" + + source: Literal['memory'] | Literal['profile'] + timestamp: NotRequired[None | float] + title: str + body: str + +class StarmapGraph(TypedDict): + nodes: list[StarmapNode] + edges: list[StarmapEdge] + clusters: list[StarmapCluster] + memory: list[StarmapMemoryCard] + stats: dict[str, OpaqueValue] + +class ContextUsageCategory(TypedDict): + color: str + id: str + label: str + tokens: float + +class ContextBreakdown(TypedDict): + categories: list[ContextUsageCategory] + context_max: float + context_percent: float + context_used: float + estimated_total: float + model: NotRequired[str] + +class AnalyticsDailyEntry(TypedDict): + actual_cost: float + api_calls: float + cache_read_tokens: float + day: str + estimated_cost: float + input_tokens: float + output_tokens: float + reasoning_tokens: float + sessions: float + +class AnalyticsModelEntry(TypedDict): + api_calls: float + estimated_cost: float + input_tokens: float + model: str + output_tokens: float + sessions: float + +class AnalyticsToolEntry(TypedDict): + count: float + percentage: float + tool: str + +class AnalyticsSkillEntry(TypedDict): + last_used_at: None | float + manage_count: float + percentage: float + skill: str + total_count: float + view_count: float + +class AnalyticsSkillsSummary(TypedDict): + distinct_skills_used: float + total_skill_actions: float + total_skill_edits: float + total_skill_loads: float + +class AnalyticsTotals(TypedDict): + total_actual_cost: float + total_api_calls: None | float + total_cache_read: None | float + total_estimated_cost: float + total_input: None | float + total_output: None | float + total_reasoning: None | float + total_sessions: float + +class CronJob(TypedDict): + deliver: NotRequired[None | str] + enabled: bool + id: str + last_error: NotRequired[None | str] + last_run_at: NotRequired[None | str] + model: NotRequired[None | str] + name: NotRequired[None | str] + next_run_at: NotRequired[None | str] + no_agent: NotRequired[bool] + prompt: NotRequired[None | str] + provider: NotRequired[None | str] + schedule: NotRequired[CronJobSchedule] + schedule_display: NotRequired[None | str] + script: NotRequired[None | str] + state: NotRequired[None | str] + +class CronJobCreatePayload(TypedDict): + deliver: NotRequired[str] + model: NotRequired[str] + name: NotRequired[str] + prompt: str + provider: NotRequired[str] + schedule: str + +class CronJobSchedule(TypedDict): + display: NotRequired[str] + expr: NotRequired[str] + kind: NotRequired[str] + +class CronJobUpdates(TypedDict): + deliver: NotRequired[str] + enabled: NotRequired[bool] + model: NotRequired[None | str] + name: NotRequired[str] + prompt: NotRequired[str] + provider: NotRequired[None | str] + schedule: NotRequired[str] + +class ProfileCreatePayload(TypedDict): + clone_all: NotRequired[bool] + clone_from: NotRequired[None | str] + clone_from_default: NotRequired[bool] + name: str + no_skills: NotRequired[bool] + +class ProfileInfo(TypedDict): + has_env: bool + is_default: bool + model: None | str + name: str + path: str + provider: None | str + skill_count: float + +class ProfileSetupCommand(TypedDict): + command: str + +class ProjectFolder(TypedDict): + path: str + label: None | str + is_primary: bool + added_at: float + +class ProjectInfo(TypedDict): + id: str + slug: str + name: str + description: None | str + icon: None | str + color: None | str + board_slug: None | str + primary_path: None | str + archived: bool + created_at: float + folders: list[ProjectFolder] + +class ProjectsPayload(TypedDict): + projects: list[ProjectInfo] + active_id: None | str + +class ProfileSoul(TypedDict): + content: str + exists: bool + +class ProfilesResponse(TypedDict): + profiles: list[ProfileInfo] + +class SkillInfo(TypedDict): + category: str + description: str + enabled: bool + name: str + usage: NotRequired[float] + """Total observed activity (use + view + patch). Absent on older backends.""" + provenance: NotRequired[Literal['agent'] | Literal['bundled'] | Literal['hub']] + """'agent' = learned/local (editable), 'bundled' = ships with Hermes, 'hub' = installed.""" + +class ToolsetInfo(TypedDict): + configured: bool + description: str + enabled: bool + label: str + name: str + tools: list[str] + +class ToolEnvVar(TypedDict): + key: str + prompt: str + url: str | None + default: str | None + is_set: bool + +# Server-computed readiness for a provider picker row. Absent on older +# backends that predate the truthful-readiness endpoint. +ToolProviderStatus: TypeAlias = Literal['ready'] | Literal['needs_setup'] | Literal['needs_auth'] | Literal['needs_keys'] + +class ToolProvider(TypedDict): + name: str + badge: str + tag: str + env_vars: list[ToolEnvVar] + post_setup: str | None + requires_nous_auth: bool + is_active: bool + """True when this is the provider currently written to config (mirrors the + CLI `hermes tools` active-provider detection).""" + status: NotRequired[ToolProviderStatus] + """Honest readiness computed server-side (keys ∧ Nous entitlement ∧ + post-setup install state). Optional for older backends.""" + web_backend: NotRequired[str] + """Web toolset only: the backend key written to web.*backend config + (e.g. 'searxng'). Absent on other toolsets and older backends.""" + tts_provider: NotRequired[str] + """TTS toolset only: the provider key written to tts.provider when this row + is selected (e.g. 'openai'). Doubles as the config section that holds the + provider's voice/model settings (tts..*). Absent on other toolsets + and older backends.""" + capabilities: NotRequired[list[WebCapability]] + """Web toolset only: capabilities this backend can serve. Search-only + providers (ddgs, brave-free) report ['search'].""" + +# A web toolset capability — the runtime dispatches web_search and +# web_extract to independently configurable backends. +WebCapability: TypeAlias = Literal['search'] | Literal['extract'] + +class ToolsetConfig(TypedDict): + name: str + has_category: bool + providers: list[ToolProvider] + active_provider: str | None + """Name of the currently active provider, or null if none is configured.""" + active_search_backend: NotRequired[str | None] + """Web toolset only: backend the web_search tool resolves to right now + (web.search_backend → web.backend → credential auto-detect).""" + active_extract_backend: NotRequired[str | None] + """Web toolset only: backend the web_extract tool resolves to right now.""" + +# Health status of a terminal execution backend row. +# +# `ready` — usable now; `needs_setup` — selectable but missing a dependency +# or credential (detail says which); `unavailable` — the probe itself failed. +TerminalBackendStatus: TypeAlias = Literal['ready'] | Literal['needs_setup'] | Literal['unavailable'] + +class TerminalBackendInfo(TypedDict): + """One row from `GET /api/tools/terminal/backends`.""" + + name: str + label: str + description: str + active: bool + """True when this backend is the current `terminal.backend` config value.""" + status: TerminalBackendStatus + detail: str + """Setup guidance / probe detail for non-ready rows (empty when ready).""" + +class TerminalBackendsResponse(TypedDict): + """Shape of `GET /api/tools/terminal/backends`.""" + + active: str + backends: list[TerminalBackendInfo] + +class ToolsetModel(TypedDict): + """One model row from a toolset backend's catalog (image/video gen).""" + + id: str + display: str + speed: str + strengths: str + price: str + +class ToolsetModelsResponse(TypedDict): + """Shape of `GET /api/tools/toolsets/{name}/models`.""" + + name: str + has_models: bool + provider: NotRequired[str | None] + plugin: NotRequired[str | None] + models: list[ToolsetModel] + current: str | None + default: str | None + +class ComputerUsePermissionSource(TypedDict): + """Shape of `GET /api/tools/computer-use/status`. + + cua-driver runs on macOS, Windows, and Linux. `ready` is the single OS-aware + readiness signal: on macOS both TCC grants (Accessibility + Screen + Recording, which attach to cua-driver's own `com.trycua.driver` identity, + not Hermes); elsewhere, driver health from `cua-driver doctor`. `null` + means unknown (binary missing / probe failed).""" + + attribution: NotRequired[str] + executable: NotRequired[str] + note: NotRequired[str] + pid: NotRequired[float] + responsible_ppid: NotRequired[float] + +class ComputerUseCheck(TypedDict): + label: str + status: str + message: str + +class ComputerUseStatus(TypedDict): + platform: str + """`sys.platform`: "darwin" | "win32" | "linux" | ...""" + platform_supported: bool + """cua-driver has a runtime backend for this platform.""" + installed: bool + """cua-driver binary resolved on PATH.""" + version: str | None + """e.g. "cua-driver 0.5.1", or null when unknown.""" + ready: bool | None + """Unified readiness — both TCC grants (macOS) or driver health (else).""" + can_grant: bool + """Whether a permission grant flow exists (macOS-only TCC).""" + checks: list[ComputerUseCheck] + """Cross-platform `cua-driver doctor` probes.""" + accessibility: bool | None + """macOS TCC detail — `null` off macOS or when unknown.""" + screen_recording: bool | None + screen_recording_capturable: bool | None + source: ComputerUsePermissionSource | None + error: str | None + """Populated when the status probe itself failed.""" + +class SessionSearchResult(TypedDict): + lineage_root: NotRequired[str | None] + """Lineage root of the matched conversation. Stable across compression and + used as the durable pin id; falls back to session_id when absent.""" + model: str | None + role: str | None + session_id: str + """Live compression tip of the matched conversation — resume by this id.""" + session_started: float | None + snippet: str + source: str | None + +class SessionSearchResponse(TypedDict): + results: list[SessionSearchResult] + +class LogsResponse(TypedDict): + file: str + lines: list[str] + +class PlatformStatus(TypedDict): + error_code: NotRequired[str] + error_message: NotRequired[str] + state: str + updated_at: str + +class StatusResponse(TypedDict): + active_sessions: float + config_path: str + config_version: float + env_path: str + gateway_exit_reason: str | None + gateway_health_url: str | None + gateway_pid: float | None + gateway_platforms: dict[str, PlatformStatus] + gateway_running: bool + gateway_state: str | None + gateway_updated_at: str | None + hermes_home: str + latest_config_version: float + release_date: str + version: str + +class ActionResponse(TypedDict): + name: str + ok: bool + pid: float + +class ActionStatusResponse(TypedDict): + exit_code: float | None + lines: list[str] + name: str + pid: float | None + running: bool + +class BackendUpdateCommit(TypedDict): + sha: str + summary: str + author: str + at: float + +class BackendUpdateCheckResponse(TypedDict): + """Shape of `GET /api/hermes/update/check` — the backend's own update state. + Used by the desktop's remote update overlay so the backend version (not the + Electron client clone) drives "what's changed + Install" in remote mode.""" + + install_method: str + current_version: str + behind: float | None + update_available: bool + can_apply: bool + update_command: str | None + message: str | None + commits: NotRequired[list[BackendUpdateCommit]] + +class AuxiliaryTaskAssignment(TypedDict): + base_url: str + model: str + provider: str + task: str + +class MoaModelSlot(TypedDict): + provider: str + model: str + reasoning_effort: NotRequired[str] + """Optional per-slot reasoning effort — round-tripped, not edited here.""" + +class ModelAssignmentRequest(TypedDict): + api_key: NotRequired[str] + """Optional API key for a custom/local endpoint. Persisted to model.api_key + (where the runtime reads it) for self-hosted endpoints that require auth. + Only honored for custom/local providers on the main slot.""" + base_url: NotRequired[str] + """OpenAI-compatible endpoint URL. Only honored for custom/local providers + on the main slot — wires a self-hosted endpoint into runtime resolution.""" + model: str + provider: str + scope: Literal['main'] | Literal['auxiliary'] + task: NotRequired[str] + +class StaleAuxAssignment(TypedDict): + """An auxiliary task still pinned to a provider that differs from the + newly-selected main provider after a main-model switch.""" + + task: str + provider: str + model: str + +class SkillHubSource(TypedDict): + """One skill-hub source (official index, GitHub, skills.sh, …) as reported by + `GET /api/skills/hub/sources`.""" + + id: str + label: str + available: NotRequired[bool] + rate_limited: NotRequired[bool] + searchable: NotRequired[bool] + +class SkillHubResult(TypedDict): + """A searchable/installable hub skill from `GET /api/skills/hub/search`.""" + + name: str + description: str + source: str + identifier: str + trust_level: str + repo: str | None + tags: list[str] + +class SkillHubInstalledEntry(TypedDict): + name: str | None + trust_level: str | None + scan_verdict: str | None + +class SkillHubSourcesResponse(TypedDict): + sources: list[SkillHubSource] + index_available: bool + featured: list[SkillHubResult] + installed: dict[str, SkillHubInstalledEntry] + +class SkillHubSearchResponse(TypedDict): + results: list[SkillHubResult] + source_counts: dict[str, float] + timed_out: list[str] + installed: dict[str, SkillHubInstalledEntry] + +class SkillHubPreview(TypedDict): + """`GET /api/skills/hub/preview` — SKILL.md + manifest without installing.""" + + name: str + description: str + source: str + identifier: str + trust_level: str + repo: str | None + tags: list[str] + skill_md: str + files: list[str] + +class SkillHubScanFinding(TypedDict): + severity: str + category: str + file: str + line: float | None + description: str + +class SkillHubScanResult(TypedDict): + """`GET /api/skills/hub/scan` — install-time security scan verdict.""" + + name: str + identifier: str + source: str + trust_level: str + verdict: str + summary: str + policy: Literal['allow'] | Literal['ask'] | Literal['block'] + policy_reason: str | None + findings: list[SkillHubScanFinding] + severity_counts: dict[str, float] + +class McpServerSummary(TypedDict): + """One configured MCP server row from `GET /api/mcp/servers`.""" + + name: str + transport: str + command: str | None + args: list[str] + url: str | None + enabled: bool + tools: list[str] | None + +class CuratorStatusResponse(TypedDict): + """`GET /api/curator` — background skill-curator status.""" + + enabled: bool + paused: bool + interval_hours: float | None + last_run_at: str | None + min_idle_hours: float | None + stale_after_days: float | None + archive_after_days: float | None + +class DebugShareResponse(TypedDict): + """`POST /api/ops/debug-share` — shareable diagnostics upload result.""" + + ok: bool + urls: dict[str, str] + failures: dict[str, str] + redacted: bool + auto_delete_seconds: float | None + +class ModelAssignmentResponse(TypedDict): + base_url: NotRequired[str] + """Persisted endpoint URL for custom/local providers (echoed back).""" + gateway_tools: NotRequired[list[str]] + """Toolset keys auto-routed through the Nous Tool Gateway as a result of + switching the main provider to Nous. Empty unless provider === 'nous' + and the user is a paid subscriber with unconfigured tools.""" + model: NotRequired[str] + ok: bool + provider: NotRequired[str] + reset: NotRequired[bool] + scope: NotRequired[str] + stale_aux: NotRequired[list[StaleAuxAssignment]] + """Auxiliary slots still pinned to a different provider than the new main. + Switching main never clears aux pins; this lets the UI warn the user + their helper tasks aren't following the switch. Only set on scope:'main'.""" + tasks: NotRequired[list[str]] + +EXPORTED_CONTRACT_NAMES = ( + ConfigFieldSchema, + ConfigSchemaResponse, + AudioTranscriptionResponse, + AudioSpeakResponse, + ElevenLabsVoice, + ElevenLabsVoicesResponse, + OAuthProviderStatus, + OAuthProvider, + OAuthProvidersResponse, + OAuthSubmitResponse, + OAuthPollResponse, + MemoryProviderOAuthStatus, + EnvVarInfo, + MemoryProviderFieldKind, + MemoryProviderFieldOption, + MemoryProviderField, + MemoryProviderConfig, + CustomEndpoint, + CustomEndpointUpdate, + CustomEndpointValidationResponse, + MessagingEnvVarInfo, + MessagingHomeChannel, + MessagingPlatformInfo, + MessagingPlatformsResponse, + MessagingPlatformUpdate, + MessagingPlatformTestResponse, + GatewayReadyPayload, + ModelInfoResponse, + ModelPricing, + ModelOptionProvider, + ModelCapabilities, + ModelOptionsResponse, + SessionCreateResponse, + SessionInfo, + SessionMessage, + SessionMessagesResponse, + UsageStats, + StarmapNode, + StarmapEdge, + StarmapCluster, + StarmapMemoryCard, + StarmapGraph, + ContextUsageCategory, + ContextBreakdown, + AnalyticsDailyEntry, + AnalyticsModelEntry, + AnalyticsToolEntry, + AnalyticsSkillEntry, + AnalyticsSkillsSummary, + AnalyticsTotals, + CronJob, + CronJobCreatePayload, + CronJobSchedule, + CronJobUpdates, + ProfileCreatePayload, + ProfileInfo, + ProfileSetupCommand, + ProjectFolder, + ProjectInfo, + ProjectsPayload, + ProfileSoul, + ProfilesResponse, + SkillInfo, + ToolsetInfo, + ToolEnvVar, + ToolProviderStatus, + ToolProvider, + WebCapability, + ToolsetConfig, + TerminalBackendStatus, + TerminalBackendInfo, + TerminalBackendsResponse, + ToolsetModel, + ToolsetModelsResponse, + ComputerUsePermissionSource, + ComputerUseCheck, + ComputerUseStatus, + SessionSearchResult, + SessionSearchResponse, + LogsResponse, + PlatformStatus, + StatusResponse, + ActionResponse, + ActionStatusResponse, + BackendUpdateCommit, + BackendUpdateCheckResponse, + AuxiliaryTaskAssignment, + MoaModelSlot, + ModelAssignmentRequest, + StaleAuxAssignment, + SkillHubSource, + SkillHubResult, + SkillHubInstalledEntry, + SkillHubSourcesResponse, + SkillHubSearchResponse, + SkillHubPreview, + SkillHubScanFinding, + SkillHubScanResult, + McpServerSummary, + CuratorStatusResponse, + DebugShareResponse, + ModelAssignmentResponse, +) + +EXPORTED_CONTRACTS = { + contract.__name__: contract + for contract in EXPORTED_CONTRACT_NAMES + if isinstance(contract, type) +} | { + "MemoryProviderFieldKind": MemoryProviderFieldKind, + "ToolProviderStatus": ToolProviderStatus, + "WebCapability": WebCapability, + "TerminalBackendStatus": TerminalBackendStatus, +} diff --git a/scripts/generate_gateway_types.py b/scripts/generate_gateway_types.py index 96cd4920896..555c03b4bbd 100644 --- a/scripts/generate_gateway_types.py +++ b/scripts/generate_gateway_types.py @@ -8,6 +8,8 @@ from pathlib import Path from tempfile import NamedTemporaryFile import ts_type as ts +from hermes_cli.contract_types import DocumentedContractBuilder +from hermes_cli.web_contracts import EXPORTED_CONTRACTS from tui_gateway.contracts import ( GatewayMcpServerStatus, GatewayProjectInfo, @@ -27,8 +29,10 @@ def main() -> None: GatewaySessionRuntimeInfo, ): ts.generator.add(contract, "gateway", contract.__name__) + for name, contract in EXPORTED_CONTRACTS.items(): + ts.generator.add(contract, "gateway", name) - source = ts.generator.render()["gateway"] + source = ts.generator.render(builder_cls=DocumentedContractBuilder)["gateway"] output = "// Generated by scripts/generate_gateway_types.py. Do not edit.\n\n" + source + "\n" OUTPUT.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/hermes_cli/test_contract_types.py b/tests/hermes_cli/test_contract_types.py new file mode 100644 index 00000000000..b539302d202 --- /dev/null +++ b/tests/hermes_cli/test_contract_types.py @@ -0,0 +1,45 @@ +"""Behavioral tests for documented shared-contract generation.""" + +from typing import NotRequired, TypedDict + +import ts_type as ts + +from hermes_cli.contract_types import DocumentedContractBuilder, OpaqueValue + + +class DocumentedContract(TypedDict, total=False): + """A contract-level docstring with a slash: */ must stay safe.""" + + name: str + """A human-readable name.""" + payload: OpaqueValue + """Data whose schema Hermes intentionally does not promise.""" + + +class MixedRequirednessContract(TypedDict): + required: str + optional: NotRequired[int] + + +def test_documented_contract_builder_renders_pep_257_docs_and_unknown_values(): + generator = ts.TypeDefinitionGenerator() + generator.add(DocumentedContract, "gateway", "DocumentedContract") + + source = generator.render(builder_cls=DocumentedContractBuilder)["gateway"] + + assert "/** A contract-level docstring with a slash: *\\/ must stay safe. */" in source + assert " /** A human-readable name. */\n \"name\"?: string;" in source + assert ( + " /** Data whose schema Hermes intentionally does not promise. */\n" + ' "payload"?: unknown;' + ) in source + + +def test_documented_contract_builder_unwraps_python_optional_field_markers(): + generator = ts.TypeDefinitionGenerator() + generator.add(MixedRequirednessContract, "gateway", "MixedRequirednessContract") + + source = generator.render(builder_cls=DocumentedContractBuilder)["gateway"] + + assert '"required": string;' in source + assert '"optional"?: number;' in source