fix(desktop): feed memory.provider dropdown from live discovery

The desktop Settings memory-provider dropdown read a hardcoded
`ENUM_OPTIONS['memory.provider'] = ['', 'honcho', 'hindsight']` list,
so user-installed and pip-installed providers never appeared even though
the backend already discovers them (`GET /api/memory` ->
`_discover_memory_provider_statuses()`) and the CLI (`hermes memory
setup`) lists them. This was the one surface left where the memory
config stack was not schema/discovery-driven.

Fetch `getMemoryStatus()` on the settings page (mirroring the existing
`elevenLabsVoiceOptions` pattern) and pass the discovered provider names
to `enumOptionsFor` as `dynamicOptions` for the `memory.provider` key.
The static `ENUM_OPTIONS` entry is demoted to a pre-load fallback; the
current-value passthrough still keeps a selected-but-undiscovered
provider visible.

Completes the desktop half of the schema-driven memory-provider config
surface (the CLI + backend + generic panel already landed via #51020 /
#67206), superseding the stale #48675 which built the same feature
against the pre-refactor layout.

Co-authored-by: brooklyn! <770929+OutThisLife@users.noreply.github.com>
This commit is contained in:
Austin Pickett 2026-07-21 23:13:50 -04:00
parent 60ec6a3b8e
commit 3493a6c73c
3 changed files with 51 additions and 3 deletions

View file

@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { getElevenLabsVoices, getHermesConfigSchema, getMemoryStatus, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
@ -76,6 +76,10 @@ export function ConfigSettings({
const schema = schemaResponse?.fields ?? null
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
// Discovered memory providers (bundled + user-installed + pip), so the
// memory.provider dropdown reflects what the backend actually serves rather
// than a hardcoded subset. null until the first fetch resolves.
const [memoryProviderOptions, setMemoryProviderOptions] = useState<string[] | null>(null)
const saveVersionRef = useRef(0)
const savedDiscoverySignatureRef = useRef<string | undefined>(undefined)
const [saveVersion, setSaveVersion] = useState(0)
@ -126,6 +130,27 @@ export function ConfigSettings({
return () => void (cancelled = true)
}, [])
useEffect(() => {
let cancelled = false
getMemoryStatus()
.then(result => {
if (cancelled) {
return
}
// Empty sentinel first (built-in only), then every discovered plugin.
setMemoryProviderOptions(['', ...result.providers.map(provider => provider.name)])
})
.catch(() => {
if (!cancelled) {
setMemoryProviderOptions(null)
}
})
return () => void (cancelled = true)
}, [])
useEffect(() => {
if (!config || saveVersion === 0) {
return
@ -308,7 +333,9 @@ 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 ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}

View file

@ -249,7 +249,9 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
// Built-in memory is not a provider plugin: the empty sentinel renders as
// "Built-in only" and a legacy literal `builtin` value is only kept visible
// via enumOptionsFor's current-value passthrough (#49513).
// via enumOptionsFor's current-value passthrough (#49513). This static list
// is only a pre-load fallback: config-settings.tsx feeds enumOptionsFor the
// live discovered set (getMemoryStatus) so user-installed/pip providers show.
'memory.provider': ['', 'honcho', 'hindsight'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/

View file

@ -44,6 +44,25 @@ describe('settings helpers', () => {
expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin'])
})
it('prefers the discovered provider set over the static fallback for memory.provider', () => {
// config-settings.tsx passes the live getMemoryStatus() providers as
// dynamicOptions; user-installed/pip providers must appear, not just the
// hardcoded honcho/hindsight subset.
const discovered = ['', 'honcho', 'hindsight', 'mem0', 'supermemory']
const options = enumOptionsFor('memory.provider', '', {}, discovered)
expect(options).toEqual(discovered)
})
it('keeps the current memory.provider value visible even if discovery omits it', () => {
// A provider selected in config but not (yet) returned by discovery must
// still render as the current selection rather than silently vanishing.
const discovered = ['', 'honcho']
const options = enumOptionsFor('memory.provider', 'hindsight', {}, discovered)
expect(options).toEqual(['', 'honcho', 'hindsight'])
})
describe('isExternalMemoryProvider', () => {
it('treats only real plugin names as external providers', () => {
expect(isExternalMemoryProvider('honcho')).toBe(true)