From e48d18c8e24354101e1fda4d4a072cdb41b89aeb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 18 Jun 2026 17:16:13 -0500 Subject: [PATCH] feat(desktop): schema-driven memory-provider config surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/memory_provider.py | 21 ++ .../src/app/settings/config-settings.tsx | 46 ++- apps/desktop/src/app/settings/constants.ts | 5 +- apps/desktop/src/app/settings/helpers.test.ts | 14 +- .../settings/provider-config-panel.test.tsx | 112 +++++-- .../app/settings/provider-config-panel.tsx | 56 +++- apps/desktop/src/hermes.ts | 8 + apps/desktop/src/types/hermes.ts | 26 +- hermes_cli/memory_providers.py | 285 ++++++++++++------ hermes_cli/web_server.py | 196 +++++++----- plugins/memory/hindsight/__init__.py | 14 + plugins/memory/holographic/__init__.py | 16 + plugins/memory/honcho/__init__.py | 14 + plugins/memory/mem0/__init__.py | 6 + tests/hermes_cli/test_memory_providers.py | 145 +++++++-- tests/hermes_cli/test_web_server.py | 97 +++--- 16 files changed, 770 insertions(+), 291 deletions(-) diff --git a/agent/memory_provider.py b/agent/memory_provider.py index 89ac40effaa..39eb148a890 100644 --- a/agent/memory_provider.py +++ b/agent/memory_provider.py @@ -276,6 +276,27 @@ class MemoryProvider(ABC): should all have ``env_var`` set and this method stays no-op). """ + def read_current_config(self) -> Dict[str, Any]: + """Return persisted config values for schema-driven setup UIs. + + Powers the desktop memory-provider config panel (and any other + editor built off ``get_config_schema()``): on open, declared fields + are pre-filled with the values the provider previously saved via + ``save_config()``. The mirror image of ``save_config()`` — it reads + back what that wrote. + + Return the provider's stored config as a flat dict keyed by the same + ``key`` names used in ``get_config_schema()``. Secret values may be + included or omitted; callers MUST treat fields marked ``secret`` as + write-only and never echo them back regardless. Default returns + ``{}`` (the UI falls back to schema defaults). Providers that persist + non-secret config should override this — usually a one-liner + delegating to the same loader ``initialize()`` uses. + + Must not raise — return ``{}`` on any error. + """ + return {} + def on_memory_write( self, action: str, diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 771ba2836f4..190d015ac47 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -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 | null>(null) const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState(null) const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState>({}) + const [memoryProviderOptions, setMemoryProviderOptions] = useState(null) + const [memoryProviderLabels, setMemoryProviderLabels] = useState>({}) 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)} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 5fc9ba134cc..432e696729e 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -239,7 +239,10 @@ 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', '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). diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 1a8d0eba994..d76e2d8c725 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -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', () => { diff --git a/apps/desktop/src/app/settings/provider-config-panel.test.tsx b/apps/desktop/src/app/settings/provider-config-panel.test.tsx index 3f3d98f1520..7b84c05004a 100644 --- a/apps/desktop/src/app/settings/provider-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/provider-config-panel.test.tsx @@ -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 { - const fields: MemoryProviderConfig['fields'] = [ - { +function field(partial: Partial & Pick): 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[] = []): 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' })) + }) }) diff --git a/apps/desktop/src/app/settings/provider-config-panel.tsx b/apps/desktop/src/app/settings/provider-config-panel.tsx index d76c0eff2c5..4b16f9c4e62 100644 --- a/apps/desktop/src/app/settings/provider-config-panel.tsx +++ b/apps/desktop/src/app/settings/provider-config-panel.tsx @@ -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): 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 { - return Object.fromEntries( - config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]) - ) + const values: Record = {} + 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 ( +
+ onChange(checked ? 'true' : 'false')} /> + {value === 'true' ? 'On' : 'Off'} +
+ ) + } + if (field.kind === 'secret') { return (
@@ -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 (
@@ -155,9 +182,22 @@ export function ProviderConfigPanel({ provider }: { provider: string }) { {expanded && (
- {config.fields.map(field => ( -