fix(desktop): show Hindsight memory provider (#37546)

* fix(desktop): show Hindsight memory provider

* feat(desktop): configure Hindsight memory provider

* fix(desktop): limit Hindsight modes to supported setup

* refactor(desktop): generic memory-provider config surface

Replace the bespoke Hindsight settings surface with a declarative,
schema-driven path so adding a memory provider is pure declaration —
no per-provider page, conditional, or endpoint.

- memory_providers.py: declarative registry. Each provider lists its
  fields {key, label, kind, default, options, secret-vs-plain}. Hindsight's
  mode is a select(cloud, local_external), so rejecting local_embedded
  falls out of generic enum validation instead of a hand-written check.
- One generic endpoint pair GET/PUT /api/memory/providers/{name}/config.
  GET returns declared fields + current values (secrets only as is_set,
  never read back); PUT validates selects against their options, writes
  plain fields to the provider config file, secrets to the env store,
  and flips memory.provider.
- ProviderConfigPanel renders straight from the schema, replacing
  hindsight-settings.tsx and the memory.provider === 'hindsight'
  conditional in config-settings.tsx — same pattern as
  toolset-config-panel.tsx off env_vars.

Scoped to memory providers; storage layout is unchanged so the runtime
Hindsight plugin reads the same config.json / HINDSIGHT_API_KEY / provider
keys as before. Tests cover the registry, endpoint behavior (defaults,
write+secret, select rejection, unknown provider, secret-never-returned),
and the generic panel.
This commit is contained in:
Ben 2026-06-18 17:48:47 -04:00 committed by GitHub
parent cbe44bf890
commit 03d9a95a74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 841 additions and 2 deletions

View file

@ -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) ? (
<ProviderConfigPanel provider={String(getNested(config, key))} />
) : null}
</div>
))}
</div>

View file

@ -239,7 +239,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'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).

View file

@ -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({

View file

@ -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['fields'][number]>[] = []): 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(<ProviderConfigPanel provider={provider} />)
}
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()
})
})

View file

@ -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<string, string> {
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 (
<>
<Select onValueChange={onChange} value={value}>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(selected?.description || field.description) && (
<span className="text-xs text-muted-foreground">{selected?.description || field.description}</span>
)}
</>
)
}
if (field.kind === 'secret') {
return (
<div className="flex flex-wrap items-center gap-2">
<Input
className="min-w-64 flex-1 font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
type="password"
value={value}
/>
{field.is_set && (
<Pill tone="primary">
<Check className="size-3" />
Set
</Pill>
)}
</div>
)
}
return (
<Input
className="font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}
export function ProviderConfigPanel({ provider }: { provider: string }) {
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [values, setValues] = useState<Record<string, string>>({})
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 <LoadingState label="Loading memory provider settings..." />
}
const secretFields = config.fields.filter(field => field.kind === 'secret')
return (
<section className="py-3">
<button
aria-expanded={expanded}
className="flex w-full items-center justify-between gap-3 rounded-lg bg-background/60 px-3 py-2 text-left hover:bg-accent/50"
onClick={() => setExpanded(open => !open)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
</span>
{secretFields.map(field => (
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
))}
</span>
</button>
{expanded && (
<div className="mt-3 grid gap-4 rounded-xl bg-background/60 p-4">
{config.fields.map(field => (
<label className="grid gap-1.5" key={field.key}>
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
{field.kind !== 'select' && field.description && (
<span className="text-xs text-muted-foreground">{field.description}</span>
)}
</label>
))}
<div className="flex justify-end">
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
</div>
</div>
)}
</section>
)
}

View file

@ -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<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
})
}
export function saveMemoryProviderConfig(
provider: string,
values: Record<string, string>
): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`,
method: 'PUT',
body: { values }
})
}
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
...profileScoped(),

View file

@ -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

View file

@ -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
(``<hermes_home>/<provider>/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)

View file

@ -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):

View file

@ -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

View file

@ -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.