diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 2d550560764..771ba2836f4 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -23,6 +23,7 @@ import { fieldCopyForSchemaKey } from './field-copy' import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' import { ModelSettings } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' +import { ProviderConfigPanel } from './provider-config-panel' function ConfigField({ schemaKey, @@ -368,6 +369,9 @@ export function ConfigSettings({ schemaKey={key} value={getNested(config, key)} /> + {key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? ( + + ) : null} ))} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 1cf7cf3ce16..5fc9ba134cc 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -239,7 +239,7 @@ export const ENUM_OPTIONS: Record = { 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'], - 'memory.provider': ['', 'builtin', 'honcho'], + 'memory.provider': ['', 'builtin', 'hindsight', 'honcho'], // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ // modal/daytona/ssh). Remote backends need extra env (image, tokens, host). diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index b65d63d3296..1a8d0eba994 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -6,6 +6,12 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' describe('settings helpers', () => { + it('lists Hindsight as a built-in desktop memory provider option', () => { + const options = enumOptionsFor('memory.provider', '', {}) + + expect(options).toContain('hindsight') + }) + describe('defineFieldCopy', () => { it('flattens nested field copy paths', () => { const copy = defineFieldCopy({ diff --git a/apps/desktop/src/app/settings/provider-config-panel.test.tsx b/apps/desktop/src/app/settings/provider-config-panel.test.tsx new file mode 100644 index 00000000000..3f3d98f1520 --- /dev/null +++ b/apps/desktop/src/app/settings/provider-config-panel.test.tsx @@ -0,0 +1,142 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MemoryProviderConfig } from '@/types/hermes' + +const getMemoryProviderConfig = vi.fn() +const saveMemoryProviderConfig = vi.fn() + +vi.mock('@/hermes', () => ({ + getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider), + saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values) +})) + +vi.mock('@/store/notifications', () => ({ + notify: vi.fn(), + notifyError: vi.fn() +})) + +function hindsightSchema(overrides: Partial[] = []): MemoryProviderConfig { + const fields: MemoryProviderConfig['fields'] = [ + { + key: 'mode', + label: 'Mode', + kind: 'select', + value: 'cloud', + description: 'How Hermes connects to Hindsight.', + placeholder: '', + is_set: true, + options: [ + { value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' }, + { value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' } + ] + }, + { + key: 'api_key', + label: 'API key', + kind: 'secret', + value: '', + description: 'Used to authenticate with the Hindsight API.', + placeholder: 'Enter Hindsight API key', + is_set: false, + options: [] + }, + { + key: 'api_url', + label: 'API URL', + kind: 'text', + value: 'https://api.hindsight.vectorize.io', + description: '', + placeholder: '', + is_set: true, + options: [] + }, + { key: 'bank_id', label: 'Bank ID', kind: 'text', value: 'hermes', description: '', placeholder: '', is_set: true, options: [] }, + { + key: 'recall_budget', + label: 'Recall budget', + kind: 'select', + value: 'mid', + description: '', + placeholder: '', + is_set: true, + options: [ + { value: 'low', label: 'low', description: '' }, + { value: 'mid', label: 'mid', description: '' }, + { value: 'high', label: 'high', description: '' } + ] + } + ] + + return { + name: 'hindsight', + label: 'Hindsight', + fields: fields.map((field, index) => ({ ...field, ...overrides[index] })) + } +} + +beforeEach(() => { + getMemoryProviderConfig.mockResolvedValue(hindsightSchema()) + saveMemoryProviderConfig.mockResolvedValue({ ok: true }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function renderPanel(provider = 'hindsight') { + const { ProviderConfigPanel } = await import('./provider-config-panel') + + return render() +} + +describe('ProviderConfigPanel', () => { + it('renders the declared provider fields generically', async () => { + await renderPanel() + + expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy() + expect(screen.getByDisplayValue('hermes')).toBeTruthy() + expect(screen.getByText('Cloud')).toBeTruthy() + expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0) + expect(screen.getByText('mid')).toBeTruthy() + }) + + it('collapses and expands the fields', async () => { + await renderPanel() + + expect(await screen.findByLabelText('API URL')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) + expect(screen.queryByLabelText('API URL')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) + expect(await screen.findByLabelText('API URL')).toBeTruthy() + }) + + it('saves edited values without requiring a secret replacement', async () => { + await renderPanel() + + const apiUrl = await screen.findByLabelText('API URL') + fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } }) + fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', { + mode: 'cloud', + api_key: '', + api_url: 'http://localhost:8888', + bank_id: 'ben-bank', + recall_budget: 'mid' + }) + ) + }) + + it('renders nothing for a provider with no declared config surface', async () => { + getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] }) + + const { container } = await renderPanel('builtin') + + await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin')) + expect(container.querySelector('section')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/settings/provider-config-panel.tsx b/apps/desktop/src/app/settings/provider-config-panel.tsx new file mode 100644 index 00000000000..d76c0eff2c5 --- /dev/null +++ b/apps/desktop/src/app/settings/provider-config-panel.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes' +import { Check, Loader2, Save } from '@/lib/icons' +import { notify, notifyError } from '@/store/notifications' +import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes' + +import { CONTROL_TEXT } from './constants' +import { LoadingState, Pill } from './primitives' + +/** Seed editable values from the schema: non-secret fields keep their current + * value, secret fields start blank (their value is never returned). */ +function seedValues(config: MemoryProviderConfig): Record { + return Object.fromEntries( + config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]) + ) +} + +function FieldControl({ + field, + value, + onChange +}: { + field: MemoryProviderField + value: string + onChange: (value: string) => void +}) { + if (field.kind === 'select') { + const selected = field.options.find(option => option.value === value) + + return ( + <> + + {(selected?.description || field.description) && ( + {selected?.description || field.description} + )} + + ) + } + + if (field.kind === 'secret') { + return ( +
+ onChange(event.target.value)} + placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder} + type="password" + value={value} + /> + {field.is_set && ( + + + Set + + )} +
+ ) + } + + return ( + onChange(event.target.value)} + placeholder={field.placeholder} + value={value} + /> + ) +} + +export function ProviderConfigPanel({ provider }: { provider: string }) { + const [config, setConfig] = useState(null) + const [values, setValues] = useState>({}) + const [expanded, setExpanded] = useState(true) + const [saving, setSaving] = useState(false) + + const refresh = useCallback(async () => { + try { + const next = await getMemoryProviderConfig(provider) + setConfig(next) + setValues(seedValues(next)) + } catch (err) { + notifyError(err, 'Memory provider settings failed to load') + setConfig(null) + } + }, [provider]) + + useEffect(() => { + setConfig(null) + void refresh() + }, [refresh]) + + const save = useCallback(async () => { + if (!config) { + return + } + + setSaving(true) + + try { + await saveMemoryProviderConfig(provider, values) + notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' }) + await refresh() + } catch (err) { + notifyError(err, `Failed to save ${config.label} settings`) + } finally { + setSaving(false) + } + }, [config, provider, refresh, values]) + + // Providers without a declared config surface (e.g. builtin) render nothing. + if (config && config.fields.length === 0) { + return null + } + + if (!config) { + return + } + + const secretFields = config.fields.filter(field => field.kind === 'secret') + + return ( +
+ + + {expanded && ( +
+ {config.fields.map(field => ( + + ))} + +
+ +
+
+ )} +
+ ) +} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 5d8d70b38a8..3b200a598f4 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -17,6 +17,7 @@ import type { HermesConfig, HermesConfigRecord, LogsResponse, + MemoryProviderConfig, MessagingPlatformsResponse, MessagingPlatformTestResponse, MessagingPlatformUpdate, @@ -71,6 +72,7 @@ export type { HermesConfig, HermesConfigRecord, LogsResponse, + MemoryProviderConfig, MessagingEnvVarInfo, MessagingHomeChannel, MessagingPlatformInfo, @@ -339,6 +341,23 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool }) } +export function getMemoryProviderConfig(provider: string): Promise { + return window.hermesDesktop.api({ + path: `/api/memory/providers/${encodeURIComponent(provider)}/config` + }) +} + +export function saveMemoryProviderConfig( + provider: string, + values: Record +): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/memory/providers/${encodeURIComponent(provider)}/config`, + method: 'PUT', + body: { values } + }) +} + export function getEnvVars(): Promise> { return window.hermesDesktop.api>({ ...profileScoped(), diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 55019fb0827..a497e3f10a9 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -113,6 +113,31 @@ export interface EnvVarInfo { url: null | string } +export type MemoryProviderFieldKind = 'secret' | 'select' | 'text' + +export interface MemoryProviderFieldOption { + description: string + label: string + value: string +} + +export interface MemoryProviderField { + description: string + is_set: boolean + key: string + kind: MemoryProviderFieldKind + label: string + options: MemoryProviderFieldOption[] + placeholder: string + value: string +} + +export interface MemoryProviderConfig { + fields: MemoryProviderField[] + label: string + name: string +} + export interface MessagingEnvVarInfo { advanced: boolean description: string diff --git a/hermes_cli/memory_providers.py b/hermes_cli/memory_providers.py new file mode 100644 index 00000000000..9915a75f6a5 --- /dev/null +++ b/hermes_cli/memory_providers.py @@ -0,0 +1,149 @@ +"""Declarative configuration schema for desktop memory providers. + +Each memory provider *declares* its configurable surface here — the fields, their +types, which values are secrets, and (for selects) the allowed options. A single +generic renderer in the desktop UI and a single generic ``GET/PUT +/api/memory/providers/{name}/config`` endpoint pair drive the whole experience, +so adding a new provider (mem0, honcho, ...) is pure declaration with zero +bespoke UI components or endpoints. + +This module is intentionally pure data: it imports nothing from the config/env +layer. ``web_server`` owns the generic read/write logic that interprets these +declarations against config.yaml, the provider config file, and the env store. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field as dataclass_field + +# Field kinds understood by the generic renderer. +KIND_TEXT = "text" +KIND_SELECT = "select" +KIND_SECRET = "secret" + + +@dataclass(frozen=True) +class ProviderFieldOption: + """A single choice for a ``select`` field.""" + + value: str + label: str + description: str = "" + + +@dataclass(frozen=True) +class ProviderField: + """One configurable field on a memory provider. + + A field is stored in exactly one place, decided by ``kind``: + + * ``text`` / ``select`` — persisted to the provider's JSON config file + (``//config.json``) under ``key``. + * ``secret`` — persisted to the env store under ``env_key`` and never read + back out over the API (only an ``is_set`` flag is surfaced). + + ``aliases`` and ``env_fallbacks`` let a field read legacy values written by + earlier CLI/env setup without re-introducing per-provider code. + """ + + key: str + label: str + kind: str = KIND_TEXT + default: str = "" + description: str = "" + placeholder: str = "" + options: tuple[ProviderFieldOption, ...] = () + env_key: str | None = None + aliases: tuple[str, ...] = () + env_fallbacks: tuple[str, ...] = () + + @property + def is_secret(self) -> bool: + return self.kind == KIND_SECRET + + def allowed_values(self) -> set[str]: + return {opt.value for opt in self.options} + + +@dataclass(frozen=True) +class MemoryProvider: + """A declared memory provider and its configurable fields.""" + + name: str + label: str + fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple) + + +HINDSIGHT = MemoryProvider( + name="hindsight", + label="Hindsight", + fields=( + ProviderField( + key="mode", + label="Mode", + kind=KIND_SELECT, + default="cloud", + description="How Hermes connects to Hindsight.", + options=( + ProviderFieldOption( + "cloud", + "Cloud", + "Hindsight Cloud API (lightweight, just needs an API key)", + ), + ProviderFieldOption( + "local_external", + "Local External", + "Connect to an existing Hindsight instance", + ), + ), + ), + ProviderField( + key="api_key", + label="API key", + kind=KIND_SECRET, + env_key="HINDSIGHT_API_KEY", + description="Used to authenticate with the Hindsight API.", + placeholder="Enter Hindsight API key", + ), + ProviderField( + key="api_url", + label="API URL", + kind=KIND_TEXT, + default="https://api.hindsight.vectorize.io", + aliases=("apiUrl",), + env_fallbacks=("HINDSIGHT_API_URL",), + ), + ProviderField( + key="bank_id", + label="Bank ID", + kind=KIND_TEXT, + default="hermes", + aliases=("bankId",), + ), + ProviderField( + key="recall_budget", + label="Recall budget", + kind=KIND_SELECT, + default="mid", + aliases=("budget",), + options=( + ProviderFieldOption("low", "low"), + ProviderFieldOption("mid", "mid"), + ProviderFieldOption("high", "high"), + ), + ), + ), +) + + +# Registry of providers that expose a desktop config surface. Providers without +# an entry here (e.g. ``builtin``) simply render no config panel. +MEMORY_PROVIDERS: dict[str, MemoryProvider] = { + HINDSIGHT.name: HINDSIGHT, +} + + +def get_memory_provider(name: str) -> MemoryProvider | None: + """Return the declared provider for ``name``, or ``None`` if undeclared.""" + + return MEMORY_PROVIDERS.get(name) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9f451ddfd0c..9a6f28a68b5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -62,6 +62,11 @@ from hermes_cli.config import ( recommended_update_command_for_method, redact_key, ) +from hermes_cli.memory_providers import ( + MemoryProvider, + ProviderField, + get_memory_provider, +) from gateway.status import ( get_running_pid, get_runtime_status_running_pid, @@ -673,6 +678,10 @@ class EnvVarReveal(BaseModel): profile: Optional[str] = None +class MemoryProviderConfigUpdate(BaseModel): + values: Dict[str, str] = {} + + class MessagingPlatformUpdate(BaseModel): enabled: Optional[bool] = None env: Dict[str, str] = {} @@ -3163,6 +3172,160 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: return config +def _memory_provider_config_path(provider: MemoryProvider) -> Path: + return get_hermes_home() / provider.name / "config.json" + + +def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]: + path = _memory_provider_config_path(provider) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + _log.warning("Failed to read memory provider config from %s", path, exc_info=True) + return {} + return data if isinstance(data, dict) else {} + + +def _read_field_value(field: ProviderField, data: Dict[str, Any]) -> str: + """Resolve the stored value for a non-secret field, honoring legacy reads.""" + + for source_key in (field.key, *field.aliases): + value = data.get(source_key) + if value: + return str(value) + + env_on_disk = load_env() + for env_key in field.env_fallbacks: + value = env_on_disk.get(env_key) + if value: + return str(value) + + return field.default + + +def _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool: + """Whether a secret field has a value anywhere it may have been written.""" + + env_on_disk = load_env() + for env_key in (field.env_key, *field.env_fallbacks): + if env_key and env_on_disk.get(env_key): + return True + return any(data.get(source_key) for source_key in (field.key, *field.aliases)) + + +def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]: + data = _read_memory_provider_file(provider) + fields: List[Dict[str, Any]] = [] + + for field in provider.fields: + entry: Dict[str, Any] = { + "key": field.key, + "label": field.label, + "kind": field.kind, + "description": field.description, + "placeholder": field.placeholder, + "options": [ + {"value": opt.value, "label": opt.label, "description": opt.description} + for opt in field.options + ], + } + + if field.is_secret: + # Secrets are write-only over the API; only expose whether one is set. + entry["value"] = "" + entry["is_set"] = _field_is_set(field, data) + else: + value = _read_field_value(field, data) + if field.kind == "select" and value not in field.allowed_values(): + value = field.default + entry["value"] = value + entry["is_set"] = bool(value) + + fields.append(entry) + + return {"name": provider.name, "label": provider.label, "fields": fields} + + +def _coerce_field_value(field: ProviderField, raw: str) -> str: + """Validate and normalize a submitted non-secret value, or raise ValueError.""" + + value = (raw or "").strip() + if field.kind == "select": + if not value: + value = field.default + if value not in field.allowed_values(): + raise ValueError(f"Invalid value for '{field.key}'") + return value + return value or field.default + + +@app.get("/api/memory/providers/{name}/config") +async def get_memory_provider_config(name: str): + provider = get_memory_provider(name) + if provider is None: + # Undeclared providers (e.g. builtin) have no config surface. Return an + # empty schema so the generic panel simply renders nothing. + return {"name": name, "label": name, "fields": []} + return _memory_provider_payload(provider) + + +@app.put("/api/memory/providers/{name}/config") +async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate): + provider = get_memory_provider(name) + if provider is None: + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") + + values = body.values or {} + + try: + existing = _read_memory_provider_file(provider) + json_values: Dict[str, Any] = {} + secrets: Dict[str, str] = {} + + for field in provider.fields: + if field.is_secret: + submitted = (values.get(field.key) or "").strip() + if submitted and field.env_key: + secrets[field.env_key] = submitted + continue + + raw = ( + values[field.key] + if field.key in values + else str(existing.get(field.key, field.default)) + ) + json_values[field.key] = _coerce_field_value(field, raw) + + config = load_config() + memory_config = config.get("memory") + if not isinstance(memory_config, dict): + memory_config = {} + config["memory"] = memory_config + memory_config["provider"] = provider.name + save_config(config) + + path = _memory_provider_config_path(provider) + path.parent.mkdir(parents=True, exist_ok=True) + existing.update(json_values) + from utils import atomic_json_write + + atomic_json_write(path, existing, mode=0o600) + + for env_key, secret in secrets.items(): + save_env_value(env_key, secret) + + return {"ok": True} + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + _log.exception("PUT /api/memory/providers/%s/config failed", name) + raise HTTPException(status_code=500, detail="Internal server error") + + @app.get("/api/config") async def get_config(profile: Optional[str] = None): with _profile_scope(profile): diff --git a/tests/hermes_cli/test_memory_providers.py b/tests/hermes_cli/test_memory_providers.py new file mode 100644 index 00000000000..9130bdaea7d --- /dev/null +++ b/tests/hermes_cli/test_memory_providers.py @@ -0,0 +1,46 @@ +"""Tests for the declarative memory-provider registry.""" + +from hermes_cli.memory_providers import ( + KIND_SECRET, + KIND_SELECT, + get_memory_provider, +) + + +def test_hindsight_is_declared(): + provider = get_memory_provider("hindsight") + + assert provider is not None + assert provider.label == "Hindsight" + assert {field.key for field in provider.fields} == { + "mode", + "api_key", + "api_url", + "bank_id", + "recall_budget", + } + + +def test_hindsight_mode_gating_is_expressed_as_select_options(): + provider = get_memory_provider("hindsight") + assert provider is not None + + mode = next(field for field in provider.fields if field.key == "mode") + assert mode.kind == KIND_SELECT + assert mode.allowed_values() == {"cloud", "local_external"} + # local_embedded is intentionally unsupported on desktop. + assert "local_embedded" not in mode.allowed_values() + + +def test_api_key_is_a_secret_bound_to_env(): + provider = get_memory_provider("hindsight") + assert provider is not None + + api_key = next(field for field in provider.fields if field.key == "api_key") + assert api_key.kind == KIND_SECRET + assert api_key.is_secret is True + assert api_key.env_key == "HINDSIGHT_API_KEY" + + +def test_unknown_provider_is_none(): + assert get_memory_provider("builtin") is None diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 4312cc08152..f03265ee678 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -264,6 +264,110 @@ class TestWebServerEndpoints: assert web_server._dashboard_local_update_managed_externally() is True + @staticmethod + def _provider_field_map(payload): + return {field["key"]: field for field in payload["fields"]} + + def test_get_memory_provider_config_returns_safe_defaults(self): + resp = self.client.get("/api/memory/providers/hindsight/config") + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "hindsight" + assert data["label"] == "Hindsight" + + fields = self._provider_field_map(data) + assert fields["mode"]["kind"] == "select" + assert fields["mode"]["value"] == "cloud" + assert {opt["value"] for opt in fields["mode"]["options"]} == {"cloud", "local_external"} + assert fields["api_url"]["value"] == "https://api.hindsight.vectorize.io" + assert fields["bank_id"]["value"] == "hermes" + assert fields["recall_budget"]["value"] == "mid" + assert fields["api_key"]["kind"] == "secret" + assert fields["api_key"]["is_set"] is False + + def test_put_memory_provider_config_writes_config_and_secret(self): + from hermes_constants import get_hermes_home + from hermes_cli.config import load_config, load_env + + resp = self.client.put( + "/api/memory/providers/hindsight/config", + json={ + "values": { + "mode": "local_external", + "api_url": "http://localhost:8888", + "api_key": "hs-test-key", + "bank_id": "ben-bank", + "recall_budget": "high", + } + }, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + assert load_config()["memory"]["provider"] == "hindsight" + assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key" + + config_path = get_hermes_home() / "hindsight" / "config.json" + provider_config = json.loads(config_path.read_text(encoding="utf-8")) + assert provider_config == { + "mode": "local_external", + "api_url": "http://localhost:8888", + "bank_id": "ben-bank", + "recall_budget": "high", + } + + def test_put_memory_provider_config_rejects_unsupported_select_value(self): + resp = self.client.put( + "/api/memory/providers/hindsight/config", + json={ + "values": { + "mode": "local_embedded", + "api_url": "http://localhost:8888", + "bank_id": "hermes", + "recall_budget": "mid", + } + }, + ) + + assert resp.status_code == 400 + + def test_put_unknown_memory_provider_returns_404(self): + resp = self.client.put( + "/api/memory/providers/nope/config", json={"values": {}} + ) + + assert resp.status_code == 404 + + def test_get_unknown_memory_provider_returns_empty_schema(self): + resp = self.client.get("/api/memory/providers/builtin/config") + + assert resp.status_code == 200 + assert resp.json()["fields"] == [] + + def test_get_memory_provider_config_does_not_return_secret(self): + self.client.put( + "/api/memory/providers/hindsight/config", + json={ + "values": { + "mode": "cloud", + "api_url": "https://api.hindsight.vectorize.io", + "api_key": "secret-value", + "bank_id": "hermes", + "recall_budget": "mid", + } + }, + ) + + resp = self.client.get("/api/memory/providers/hindsight/config") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert fields["api_key"]["is_set"] is True + assert fields["api_key"]["value"] == "" + assert "secret-value" not in json.dumps(data) + # ── GET /api/media (remote image display) ─────────────────────────── def test_get_media_serves_image_in_root(self): @@ -377,7 +481,6 @@ class TestWebServerEndpoints: assert config["dashboard"]["theme"] == "ember" assert config["dashboard"]["font"] == "jetbrains-mono" - def test_get_sessions_uses_only_persisted_cwd(self, monkeypatch): """Session rows without persisted cwd must not inherit TERMINAL_CWD.