feat(desktop): schema-driven memory-provider config surface

Make the desktop memory settings dynamic instead of hardcoded per
provider. The dropdown is now populated from discover_memory_providers()
(bundled + user-installed + pip) rather than a static enum, and each
provider's config panel is derived from its own get_config_schema() —
the same declaration `hermes memory setup` uses — so adding or porting a
provider is pure declaration with no bespoke UI, conditional, or
endpoint.

- memory_providers.py: reworked from a hand-written Hindsight registry
  into a pure adapter (describe_provider + coerce_value) that normalizes
  a provider's raw schema into typed fields — secret(+env), select,
  boolean, typed text — carrying `when` conditionals, url, and required.
- MemoryProvider ABC: add optional read_current_config() (default {}),
  the read-back mirror of save_config(). Ported mem0, hindsight, honcho,
  holographic; the rest fall back to schema defaults safely.
- web_server GET/PUT /api/memory/providers/{name}/config now load the
  live provider, derive its schema, write non-secrets via the provider's
  own save_config() (each keeps its native storage), persist secrets to
  the env store, and `when`-gate validation so hidden fields aren't
  required or written. Secrets stay write-only (is_set only).
- ProviderConfigPanel: `when`-conditional visibility (handles Hindsight's
  mode-gated duplicate keys), boolean toggle, and credential url links.
  Dropdown driven by getMemoryStatus(); hardcoded enum removed.

Tests assert the mapping contract and endpoint behavior (schema
derivation, save-via-save_config, secret-never-returned, when-gating,
select rejection) against real bundled providers rather than a snapshot
of a hardcoded list.
This commit is contained in:
Brooklyn Nicholson 2026-06-18 17:16:13 -05:00
parent 03d9a95a74
commit e48d18c8e2
16 changed files with 770 additions and 291 deletions

View file

@ -11,6 +11,7 @@ import {
getHermesConfigDefaults,
getHermesConfigRecord,
getHermesConfigSchema,
getMemoryStatus,
saveHermesConfig
} from '@/hermes'
import { useI18n } from '@/i18n'
@ -198,6 +199,8 @@ export function ConfigSettings({
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
const [memoryProviderOptions, setMemoryProviderOptions] = useState<string[] | null>(null)
const [memoryProviderLabels, setMemoryProviderLabels] = useState<Record<string, string>>({})
const saveVersionRef = useRef(0)
const [saveVersion, setSaveVersion] = useState(0)
@ -240,6 +243,37 @@ export function ConfigSettings({
return () => void (cancelled = true)
}, [])
// Memory provider dropdown is driven by backend discovery (bundled +
// user-installed + pip plugins), not a hardcoded enum — every discovered
// provider shows up automatically. '' is the built-in (MEMORY.md/USER.md).
useEffect(() => {
let cancelled = false
getMemoryStatus()
.then(status => {
if (cancelled) {
return
}
const names = status.providers.map(p => p.name)
setMemoryProviderOptions(['', ...names])
setMemoryProviderLabels({
'': 'Built-in (MEMORY.md / USER.md)',
...Object.fromEntries(
status.providers.map(p => [p.name, p.description ? `${p.name} — ${p.description}` : p.name])
)
})
})
.catch(() => {
if (!cancelled) {
setMemoryProviderOptions(null)
setMemoryProviderLabels({})
}
})
return () => void (cancelled = true)
}, [])
useEffect(() => {
if (!config || saveVersion === 0) {
return
@ -361,10 +395,18 @@ export function ConfigSettings({
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
: key === 'memory.provider'
? enumOptionsFor(key, getNested(config, key), config, memoryProviderOptions ?? [''])
: enumOptionsFor(key, getNested(config, key), config)
}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
optionLabels={
key === 'tts.elevenlabs.voice_id'
? elevenLabsVoiceLabels
: key === 'memory.provider'
? memoryProviderLabels
: undefined
}
schema={field}
schemaKey={key}
value={getNested(config, key)}

View file

@ -239,7 +239,10 @@ 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', 'hindsight', 'honcho'],
// memory.provider is intentionally absent: the dropdown is populated at
// runtime from backend discovery (discover_memory_providers) in
// config-settings.tsx, so bundled + user-installed + pip providers all show
// up without hand-editing this list.
// 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,10 +6,18 @@ 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', '', {})
it('has no hardcoded memory.provider enum (driven by backend discovery)', () => {
// The dropdown is populated at runtime from discover_memory_providers();
// there must be no static enum here or new providers won't appear.
expect(enumOptionsFor('memory.provider', '', {})).toBeUndefined()
})
expect(options).toContain('hindsight')
it('keeps the current memory provider selectable while discovery loads', () => {
// config-settings passes [''] as the fallback before discovery resolves;
// the active provider is appended so it never vanishes from the dropdown.
const options = enumOptionsFor('memory.provider', 'hindsight', {}, [''])
expect(options).toEqual(['', 'hindsight'])
})
describe('defineFieldCopy', () => {

View file

@ -1,7 +1,7 @@
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'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
@ -16,62 +16,65 @@ vi.mock('@/store/notifications', () => ({
notifyError: vi.fn()
}))
function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
const fields: MemoryProviderConfig['fields'] = [
{
function field(partial: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key'>): MemoryProviderField {
return {
default: '',
description: '',
is_set: false,
kind: 'text',
label: partial.key,
options: [],
placeholder: '',
required: false,
url: '',
value: '',
value_type: 'str',
when: [],
...partial
}
}
function hindsightSchema(overrides: Partial<MemoryProviderField>[] = []): MemoryProviderConfig {
const fields: MemoryProviderField[] = [
field({
key: 'mode',
label: 'Mode',
kind: 'select',
value: 'cloud',
description: 'How Hermes connects to Hindsight.',
placeholder: '',
is_set: true,
description: 'How Hermes connects to Hindsight.',
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' }
]
},
{
}),
field({
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: [] },
{
placeholder: 'Enter Hindsight API key'
}),
field({ key: 'api_url', label: 'API URL', value: 'https://api.hindsight.vectorize.io', is_set: true }),
field({ key: 'bank_id', label: 'Bank ID', value: 'hermes', is_set: true }),
field({
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] }))
fields: fields.map((f, index) => ({ ...f, ...overrides[index] }))
}
}
@ -139,4 +142,55 @@ describe('ProviderConfigPanel', () => {
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
it('shows and hides fields based on their when-clause as the mode changes', async () => {
getMemoryProviderConfig.mockResolvedValue({
name: 'hindsight',
label: 'Hindsight',
fields: [
field({
key: 'mode',
label: 'Mode',
kind: 'select',
value: 'cloud',
options: [
{ value: 'cloud', label: 'Cloud', description: '' },
{ value: 'local_embedded', label: 'Local Embedded', description: '' }
]
}),
field({ key: 'api_url', label: 'API URL', value: 'https://api', when: [{ key: 'mode', value: 'cloud' }] }),
field({ key: 'llm_model', label: 'LLM model', value: 'gpt-4o-mini', when: [{ key: 'mode', value: 'local_embedded' }] })
]
})
await renderPanel()
// Cloud is selected: the cloud-gated field shows, the embedded one doesn't.
expect(await screen.findByLabelText('API URL')).toBeTruthy()
expect(screen.queryByLabelText('LLM model')).toBeNull()
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'Local Embedded' }))
// Switching to local_embedded flips which gated field is visible.
expect(await screen.findByLabelText('LLM model')).toBeTruthy()
expect(screen.queryByLabelText('API URL')).toBeNull()
})
it('renders a boolean field as a toggle and saves it as a string', async () => {
getMemoryProviderConfig.mockResolvedValue({
name: 'hindsight',
label: 'Hindsight',
fields: [field({ key: 'auto_recall', label: 'Auto recall', kind: 'boolean', value: 'true', value_type: 'bool', is_set: true })]
})
await renderPanel()
const toggle = await screen.findByRole('switch')
expect(toggle).toBeTruthy()
fireEvent.click(toggle)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', { auto_recall: 'false' }))
})
})

View file

@ -4,6 +4,7 @@ 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 { Switch } from '@/components/ui/switch'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { Check, Loader2, Save } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
@ -12,12 +13,28 @@ 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). */
/** A field is active only when every clause in its `when` matches the current
* values (e.g. Hindsight's `api_url` shows only `when` `mode === 'cloud'`). */
function whenMatches(field: MemoryProviderField, values: Record<string, string>): boolean {
return field.when.every(clause => String(values[clause.key] ?? '') === clause.value)
}
/** Seed editable values from the schema. Secrets always start blank (their
* value is never returned). Conditional fields are seeded against the
* unconditional ones first so duplicate keys (same key, different `when`)
* resolve to the variant that matches the current selection. */
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(
config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])
)
const values: Record<string, string> = {}
const seed = (field: MemoryProviderField) => {
values[field.key] = field.kind === 'secret' ? '' : field.value
}
config.fields.filter(f => f.when.length === 0).forEach(seed)
config.fields.filter(f => f.when.length > 0 && whenMatches(f, values)).forEach(seed)
// Backfill any key not yet seeded (hidden variants) so saves never send undefined.
config.fields.filter(f => !(f.key in values)).forEach(seed)
return values
}
function FieldControl({
@ -53,6 +70,15 @@ function FieldControl({
)
}
if (field.kind === 'boolean') {
return (
<div className="flex items-center gap-2">
<Switch checked={value === 'true'} onCheckedChange={checked => onChange(checked ? 'true' : 'false')} />
<span className="text-xs text-muted-foreground">{value === 'true' ? 'On' : 'Off'}</span>
</div>
)
}
if (field.kind === 'secret') {
return (
<div className="flex flex-wrap items-center gap-2">
@ -133,6 +159,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
}
const secretFields = config.fields.filter(field => field.kind === 'secret')
const visibleFields = config.fields.filter(field => whenMatches(field, values))
return (
<section className="py-3">
@ -155,9 +182,22 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
{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>
{visibleFields.map((field, index) => (
<label className="grid gap-1.5" key={`${field.key}-${index}`}>
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
{field.label}
{field.required && <span className="text-destructive">*</span>}
{field.url && (
<a
className="font-normal text-primary underline-offset-2 hover:underline"
href={field.url}
rel="noreferrer"
target="_blank"
>
Get key
</a>
)}
</span>
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}

View file

@ -18,6 +18,7 @@ import type {
HermesConfigRecord,
LogsResponse,
MemoryProviderConfig,
MemoryStatus,
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
@ -341,6 +342,13 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
})
}
export function getMemoryStatus(): Promise<MemoryStatus> {
return window.hermesDesktop.api<MemoryStatus>({
...profileScoped(),
path: '/api/memory'
})
}
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`

View file

@ -113,7 +113,7 @@ export interface EnvVarInfo {
url: null | string
}
export type MemoryProviderFieldKind = 'secret' | 'select' | 'text'
export type MemoryProviderFieldKind = 'boolean' | 'secret' | 'select' | 'text'
export interface MemoryProviderFieldOption {
description: string
@ -121,7 +121,15 @@ export interface MemoryProviderFieldOption {
value: string
}
/** A conditional-visibility clause: the field is shown only when the field
* named `key` currently equals `value`. */
export interface MemoryProviderFieldWhen {
key: string
value: string
}
export interface MemoryProviderField {
default: string
description: string
is_set: boolean
key: string
@ -129,7 +137,11 @@ export interface MemoryProviderField {
label: string
options: MemoryProviderFieldOption[]
placeholder: string
required: boolean
url: string
value: string
value_type: string
when: MemoryProviderFieldWhen[]
}
export interface MemoryProviderConfig {
@ -138,6 +150,18 @@ export interface MemoryProviderConfig {
name: string
}
export interface MemoryProviderInfo {
configured: boolean
description: string
name: string
}
export interface MemoryStatus {
active: string
builtin_files: Record<string, number>
providers: MemoryProviderInfo[]
}
export interface MessagingEnvVarInfo {
advanced: boolean
description: string