Desktop memory provider dropdown and config schema fixes
Cluster 49513 · apps/desktop. Data as of
Nothing open on GitHub. Fix merged in #67206 on 19 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #67206. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #67206. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests address a bug where the Desktop UI's memory provider dropdown incorrectly treats built-in memory as a plugin, along with related config schema and declarative panel changes to support correct behavior.
Open issues0
Open PRs0
Fixes on record
-
#67206 feat(desktop): declarative memory provider panel + built-in fix (salvage #51020, fixes #49513)
files:
apps/desktop/src/app/settings/config-settings.tsx,apps/desktop/src/app/settings/constants.ts,apps/desktop/src/app/settings/helpers.test.ts,apps/desktop/src/app/settings/helpers.ts,apps/desktop/src/app/settings/memory/field-control.tsx,apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx,apps/desktop/src/app/settings/memory/provider-config-modal.tsx,apps/desktop/src/app/settings/memory/provider-config-panel.test.tsx,apps/desktop/src/app/settings/memory/provider-config-panel.tsx,apps/desktop/src/app/settings/provider-config-panel.test.tsx,apps/desktop/src/app/settings/provider-config-panel.tsx,apps/desktop/src/hermes-profile-scope.test.ts,apps/desktop/src/hermes.ts,apps/desktop/src/i18n/en.ts,apps/desktop/src/i18n/ja.ts,apps/desktop/src/i18n/types.ts,apps/desktop/src/i18n/zh-hant.ts,apps/desktop/src/i18n/zh.ts,apps/desktop/src/types/hermes.ts,hermes_cli/memory_providers.py(+8 more)Show the diff
-
apps/desktop/src/app/settings/config-settings.tsx@@ -18,14 +18,21 @@ import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' -import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' +import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS } from './constants' import { FallbackModelsField } from './fallback-models-field' import { fieldCopyForSchemaKey } from './field-copy' -import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' +import { + enumOptionsFor, + getNested, + isExternalMemoryProvider, + prettyName, + sectionFieldEntries, + setNested +} from './helpers' import { MemoryConnect } from './memory/connect' +import { ProviderConfigPanel } from './memory/provider-config-panel' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' -import { ProviderConfigPanel } from './provider-config-panel' // On the Voice page, only surface the sub-fields of the *selected* TTS/STT // provider — otherwise every provider's options render at once (the "totally @@ -134,7 +141,9 @@ function ConfigField({ ? (optionLabels?.[option] ?? prettyName(option)) : schemaKey === 'display.personality' ? c.none - : c.noneParen} + : schemaKey === 'memory.provider' + ? c.builtinOnly + : c.noneParen} </SelectItem> ))} </SelectContent> @@ -336,14 +345,12 @@ export function ConfigSettings({ } const sectionFields = useMemo(() => { - if (!schema) { + if (!schema || !config) { return new Map<string, [string, ConfigFieldSchema][]>() } - return new Map( - SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))]) - ) - }, [schema]) + return sectionFieldEntries(schema, config) + }, [schema, config]) const fields = sectionFields.get(activeSectionId) ?? [] @@ -459,7 +466,7 @@ export function ConfigSettings({ <div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}> <ConfigField descriptionExtra={ - key === 'memory.provider' && Boolean(getNested(config, key)) ? ( + key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? ( <MemoryConnect provider={String(getNested(config, key))} /> ) : undefined } @@ -474,8 +481,8 @@ 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))} /> + {key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? ( + <ProviderConfigPanel key={String(getNested(config, key))} provider={String(getNested(config, key))} /> ) : null} </div> ))} -
apps/desktop/src/app/settings/constants.ts@@ -247,7 +247,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', 'max', 'ultra'], - 'memory.provider': ['', 'builtin', 'hindsight', 'honcho'], + // 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). + 'memory.provider': ['', 'honcho', 'hindsight'], // 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). -
apps/desktop/src/app/settings/helpers.test.ts@@ -3,13 +3,43 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy' -import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' +import { + enumOptionsFor, + getNested, + isExternalMemoryProvider, + providerGroup, + sectionFieldEntries, + setNested, + stripToolsetLabel, + toolsetDisplayLabel +} from './helpers' describe('settings helpers', () => { - it('lists Hindsight as a built-in desktop memory provider option', () => { + it('lists the desktop memory provider options in their declared order', () => { const options = enumOptionsFor('memory.provider', '', {}) - expect(options).toContain('hindsight') + // Built-in memory is not a provider plugin; the empty sentinel is the + // only built-in-shaped entry (#49513). + expect(options).toEqual(['', 'honcho', 'hindsight']) + }) + + it('keeps a legacy literal builtin value visible as the current selection', () => { + const options = enumOptionsFor('memory.provider', 'builtin', {}) + + expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin']) + }) + + describe('isExternalMemoryProvider', () => { + it('treats only real plugin names as external providers', () => { + expect(isExternalMemoryProvider('honcho')).toBe(true) + expect(isExternalMemoryProvider('hindsight')).toBe(true) + }) + + it('treats built-in aliases and empty values as not external', () => { + for (const value of ['', 'builtin', 'built-in', 'Builtin', 'none', ' ', undefined, null, 7]) { + expect(isExternalMemoryProvider(value)).toBe(false) + } + }) }) describe('defineFieldCopy', () => { @@ -176,4 +206,39 @@ describe('settings helpers', () => { expect(opts).toContain('xai') }) }) + + describe('sectionFieldEntries', () => { + it('renders memory.provider from config even when the backend schema omits it', () => { + const schema = { 'memory.memory_enabled': { type: 'boolean' as const } } + const config: HermesConfigRecord = { memory: { memory_enabled: true, provider: '' } } + + const memoryKeys = (sectionFieldEntries(schema, config).get('memory') ?? []).map(([key]) => key) + + expect(memoryKeys).toContain('memory.provider') + }) + + it('infers the field type from the config value when the schema omits the key', () => { + const config: HermesConfigRecord = { memory: { provider: '', memory_enabled: true, memory_char_limit: 2200 } } + + const fields = new Map(sectionFieldEntries({}, config).get('memory') ?? []) + + expect(fields.get('memory.provider')?.type).toBe('string') + expect(fields.get('memory.memory_enabled')?.type).toBe('boolean') + expect(fields.get('memory.memory_char_limit')?.type).toBe('number') + }) + + it('prefers the backend schema entry over inference when both exist', () => { + const schema = { 'memory.provider': { type: 'select' as const, options: ['honcho'] } } + const config: HermesConfigRecord = { memory: { provider: 'honcho' } } + + const field = new Map(sectionFieldEntries(schema, config).get('memory') ?? []).get('memory.provider') + + expect(field?.type).toBe('select') + expect(field?.options).toEqual(['honcho']) + }) + + it('hides declared keys absent from both schema and config', () => { + expect(sectionFieldEntries({}, {}).get('memory') ?? []).toHaveLength(0) + }) + }) }) -
apps/desktop/src/app/settings/helpers.ts@@ -1,7 +1,7 @@ import { asText } from '@/lib/text' -import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes' +import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes' -import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants' +import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants' // Canonical implementations live in @/lib/text; re-exported here so the many // settings/capabilities call sites keep their import path. @@ -97,6 +97,40 @@ export function getNested(obj: HermesConfigRecord, path: string): unknown { return cur } +export function inferFieldSchema(value: unknown): ConfigFieldSchema { + if (typeof value === 'boolean') { + return { type: 'boolean' } + } + + if (typeof value === 'number') { + return { type: 'number' } + } + + if (Array.isArray(value)) { + return { type: 'list' } + } + + return { type: 'string' } +} + +// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal. +export function sectionFieldEntries( + schema: Record<string, ConfigFieldSchema>, + config: HermesConfigRecord +): Map<string, [string, ConfigFieldSchema][]> { + return new Map( + SECTIONS.map(s => [ + s.id, + s.keys.flatMap(k => { + const value = getNested(config, k) + const field = schema[k] ?? (value === undefined ? undefined : inferFieldSchema(value)) + + return field ? [[k, field] as [string, ConfigFieldSchema]] : [] + }) + ]) + ) +} + export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord { const clone = structuredClone(obj) const parts = configPathParts(path) @@ -148,3 +182,16 @@ export function enumOptionsFor( return current && !opts.includes(current) ? [...opts, current] : opts } + +// Built-in memory (MEMORY.md/USER.md) is controlled by memory_enabled, not +// memory.provider — only a real external plugin name gets provider-shaped +// affordances (config panel, OAuth connect). See #49513. +export function isExternalMemoryProvider(value: unknown): value is string { + if (typeof value !== 'string') { + return false + } + + const normalized = value.trim().toLowerCase() + + return normalized !== '' && normalized !== 'builtin' && normalized !== 'built-in' && normalized !== 'none' +} -
apps/desktop/src/app/settings/memory/field-control.tsx@@ -0,0 +1,128 @@ +import { Input } from '@/components/ui/input' +import { Tip } from '@/components/ui/tooltip' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Check, Info } from '@/lib/icons' +import type { MemoryProviderField } from '@/types/hermes' + +import { CONTROL_TEXT } from '../constants' + +// Fade the placeholder well below set values so example text never reads as data. +const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45` + +// Field label with an optional info tooltip, shared by the panel and modal rows. +export function FieldTitle({ field }: { field: MemoryProviderField }) { + if (!field.info) { + return <>{field.label}</> + } + + return ( + <span className="inline-flex items-center gap-1.5"> + {field.label} + <Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}> + <Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" /> + </Tip> + </span> + ) +} + +// Values are edited as strings; the backend coerces them to native types. +export function FieldControl({ + field, + value, + onChange, + onCommit +}: { + field: MemoryProviderField + value: string + onChange: (value: string) => void + // Present on autosaving surfaces: discrete controls commit on change, text-like + // controls commit on blur. Absent (the modal), edits stay drafts until Save. + onCommit?: (value: string) => void +}) { + const set = (next: string) => { + onChange(next) + onCommit?.(next) + } + const commitDraft = onCommit ? () => onCommit(value) : undefined + + if (field.kind === 'bool') { + return <Switch checked={value === 'true'} onCheckedChange={checked => set(checked ? 'true' : 'false')} /> + } + + if (field.kind === 'number') { + return ( + <Input + className={FIELD_INPUT} + inputMode="numeric" + onBlur={commitDraft} + onChange={event => onChange(event.target.value)} + placeholder={field.placeholder} + type="number" + value={value} + /> + ) + } + + if (field.kind === 'json') { + return ( + <Textarea + className={FIELD_INPUT} + onBlur={commitDraft} + onChange={event => onChange(event.target.value)} + placeholder={field.placeholder} + spellCheck={false} + value={value} + /> + ) + } + + if (field.kind === 'select') { + return ( + <Select onValueChange={set} 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> + ) + } + + if (field.kind === 'secret') { + return ( + <div className="flex flex-col gap-1"> + <Input + className={`w-full ${FIELD_INPUT}`} + onBlur={commitDraft} + 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 && ( + <span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)"> + <Check className="size-3 text-(--ui-accent-secondary)" /> + set + </span> + )} + </div> + ) + } + + return ( + <Input + className={FIELD_INPUT} + onBlur={commitDraft} + onChange={event => onChange(event.target.value)} + placeholder={field.placeholder} + value={value} + /> + ) +} -
apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx@@ -0,0 +1,112 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes' + +const saveMemoryProviderConfig = vi.fn() + +vi.mock('@/hermes', () => ({ + saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values) +})) + +vi.mock('@/store/profile', async () => { + const { atom } = await import('nanostores') + return { $activeGatewayProfile: atom('default') } +}) + +vi.mock('@/store/notifications', () => ({ + notify: vi.fn(), + notifyError: vi.fn() +})) + +function field( + overrides: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key' | 'kind'> +): MemoryProviderField { + return { + label: overrides.key, + value: '', + description: '', + placeholder: '', + is_set: false, + inline: false, + group: 'Other', + options: [], + ...overrides + } +} + +function schema(): MemoryProviderConfig { + return { + name: 'honcho', + label: 'Honcho', + docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes', + fields: [ + field({ key: 'workspace', kind: 'text', label: 'Workspace', value: 'myws', inline: true, group: 'Connection' }), + field({ key: 'saveMessages', kind: 'bool', label: 'Save messages', value: 'true', group: 'Message writing' }), + field({ key: 'dialecticMaxChars', kind: 'number', label: 'Max result chars', value: '1200', group: 'Dialectic' }), + field({ + key: 'userPeerAliases', + kind: 'json', + label: 'User peer aliases', + value: '{"t":"eri"}', + group: 'Identity' + }) + ] + } +} + +beforeEach(() => { + saveMemoryProviderConfig.mockResolvedValue({ ok: true }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function renderModal(open = true) { + const { ProviderConfigModal } = await import('./provider-config-modal') + const onOpenChange = vi.fn() + const onSaved = vi.fn().mockResolvedValue(undefined) + const result = render( + <ProviderConfigModal + config={schema()} + onOpenChange={onOpenChange} + onSaved={onSaved} + open={open} + provider="honcho" + /> + ) + return { ...result, onOpenChange, onSaved } +} + +describe('ProviderConfigModal', () => { + it('renders every field grouped, including inline ones, with kind-specific controls', async () => { + await renderModal() + + expect(await screen.findByText('Message writing')).toBeTruthy() + expect(screen.getByText('Dialectic')).toBeTruthy() + // bool -> switch, number -> spinbutton, json/text -> textbox + expect(screen.getByRole('switch')).toBeTruthy() + expect(screen.getByDisplayValue('1200')).toBeTruthy() + expect(screen.getByDisplayValue('myws')).toBeTruthy() + expect(screen.getByDisplayValue('{"t":"eri"}')).toBeTruthy() + }) + + it('saves only edited fields, serializing the toggled bool to "false"', async () => { + const { onSaved, onOpenChange } = await renderModal() + + fireEvent.click(await screen.findByRole('switch')) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + + // A save must never ratify rendered defaults the backend does not store. + await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { saveMessages: 'false' })) + await waitFor(() => expect(onSaved).toHaveBeenCalled()) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('renders nothing while closed', async () => { + await renderModal(false) + expect(screen.queryByText('Message writing')).toBeNull() + }) +}) -
apps/desktop/src/app/settings/memory/provider-config-modal.tsx@@ -0,0 +1,155 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { saveMemoryProviderConfig } from '@/hermes' +import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons' +import { notify, notifyError } from '@/store/notifications' +import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes' + +import { $activeGatewayProfile } from '@/store/profile' + +import { FieldControl, FieldTitle } from './field-control' +import { ListRow } from '../primitives' + +// Secrets seed blank: values are write-only and blank keeps the stored one. +function seedAll(config: MemoryProviderConfig): Record<string, string> { + return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])) +} + +// Group fields in declared order, preserving first-seen group sequence. +function groupFields(fields: MemoryProviderField[]): [string, MemoryProviderField[]][] { + const groups: [string, MemoryProviderField[]][] = [] + for (const field of fields) { + const name = field.group || 'Other' + const bucket = groups.find(([key]) => key === name) + if (bucket) { + bucket[1].push(field) + } else { + groups.push([name, [field]]) + } + } + return groups +} + +export function ProviderConfigModal({ + config, + provider, + open, + onOpenChange, + onSaved +}: { + config: MemoryProviderConfig + provider: string + open: boolean + onOpenChange: (open: boolean) => void + onSaved: () => Promise<void> | void +}) { + const activeProfile = useStore($activeGatewayProfile) + const [values, setValues] = useState<Record<string, string>>({}) + const [seeded, setSeeded] = useState<Record<string, string>>({}) + const [saving, setSaving] = useState(false) + + // Reseed on open so edits never start from a stale prior-session snapshot. + useEffect(() => { + if (open) { + const seed = seedAll(config) + setSeeded(seed) + setValues(seed) + } + }, [open, config]) + + const save = async () => { + // Untouched keys stay unsubmitted; runtime defaults still own their values. + const edited = Object.fromEntries(Object.entries(values).filter(([key, value]) => value !== seeded[key])) + + setSaving(true) + try { + await saveMemoryProviderConfig(provider, edited) + notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' }) + await onSaved() + onOpenChange(false) + } catch (err) { + notifyError(err, `Failed to save ${config.label} settings`) + } finally { + setSaving(false) + } + } + + return ( + <Dialog onOpenChange={onOpenChange} open={open}> + <DialogContent className="max-w-2xl dt-portal-scrollbar"> + <DialogHeader> + <DialogTitle icon={SlidersHorizontal}>{config.label} — full configuration</DialogTitle> + <DialogDescription> + Every {config.label} option for the <span className="font-medium">{activeProfile}</span> profile. Blank + fields fall back to the resolved host or built-in default. + </DialogDescription> + {config.docs_url && ( + <a + className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline" + href={config.docs_url} + onClick={event => { + event.preventDefault() + void window.hermesDesktop?.openExternal?.(config.docs_url) + }} + rel="noreferrer" + target="_blank" + > + {config.label} configuration reference + <ExternalLink className="size-3" /> + </a> + )} + </DialogHeader> + + <div className="min-w-0"> + {groupFields(config.fields).map(([group, fields]) => ( + <section className="mt-6 first:mt-2" key={group}> + <h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)"> + {group} + </h3> + <div className="pl-1"> + {fields.map(field => ( + <div className="border-b border-border/40 last:border-b-0" key={field.key}> + <ListRow + action={ + <FieldControl + field={field} + onChange={value => setValues(current => ({ ...current, [field.key]: value }))} + value={values[field.key] ?? ''} + /> + } + description={field.description} + title={<FieldTitle field={field} />} + /> + </div> + ))} + </div> + </section> + ))} + </div> + + <DialogFooter> + <DialogClose asChild> + <Button size="sm" type="button" variant="ghost"> + Cancel + </Button> + </DialogClose> + <Button disabled={saving} onClick={() => void save()} size="sm"> + {saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />} + Save changes + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} -
apps/desktop/src/app/settings/memory/provider-config-panel.test.tsx@@ -0,0 +1,208 @@ +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/profile', async () => { + const { atom } = await import('nanostores') + return { $activeGatewayProfile: atom('default') } +}) + +vi.mock('@/store/notifications', () => ({ + notify: vi.fn(), + notifyError: vi.fn() +})) + +function honchoSchema(): MemoryProviderConfig { + return { + name: 'honcho', + label: 'Honcho', + docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes', + fields: [ + { + key: 'apiKey', + label: 'API key', + kind: 'secret', + value: '', + description: 'Authenticate with Honcho Cloud.', + placeholder: 'Enter Honcho API key', + is_set: false, + inline: true, + group: 'Connection', + options: [] + }, + { + key: 'baseUrl', + label: 'Base URL', + kind: 'text', + value: '', + description: 'Self-hosted Honcho URL.', + placeholder: 'https://… (self-hosted)', + is_set: false, + inline: true, + group: 'Connection', + options: [] + }, + { + key: 'environment', + label: 'Environment', + kind: 'select', + value: 'production', + description: 'Honcho environment.', + placeholder: '', + is_set: true, + inline: true, + group: 'Connection', + options: [ + { value: 'production', label: 'Production', description: '' }, + { value: 'demo', label: 'Demo', description: '' }, + { value: 'local', label: 'Local', description: '' } + ] + }, + { + key: 'workspace', + label: 'Workspace', + kind: 'text', + value: 'myws', + description: 'Honcho workspace ID.', + placeholder: 'hermes', + is_set: true, + inline: true, + group: 'Connection', + options: [] + }, + // Non-inline field: must NOT render in the compact panel and must NOT be + // submitted when the panel saves. + { + key: 'writeFrequency', + label: 'Write frequency', + kind: 'text', + value: 'async', + description: '', + placeholder: '', + is_set: true, + inline: false, + group: 'Message writing', + options: [] + } + ] + } +} + +beforeEach(() => { + getMemoryProviderConfig.mockResolvedValue(honchoSchema()) + saveMemoryProviderConfig.mockResolvedValue({ ok: true }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function renderPanel(provider = 'honcho') { + const { ProviderConfigPanel } = await import('./provider-config-panel') + + return render(<ProviderConfigPanel provider={provider} />) +} + +describe('ProviderConfigPanel', () => { + it('renders the declared inline fields generically', async () => { + await renderPanel() + + expect(await screen.findByDisplayValue('myws')).toBeTruthy() + expect(screen.getByPlaceholderText('https://… (self-hosted)')).toBeTruthy() + expect(screen.getByText('Production')).toBeTruthy() + expect(screen.getByText('Self-hosted Honcho URL.')).toBeTruthy() + }) + + it('hides fields that are not marked inline', async () => { + await renderPanel() + + await screen.findByDisplayValue('myws') + expect(screen.queryByDisplayValue('async')).toBeNull() + expect(screen.queryByText('Write frequency')).toBeNull() + }) + + it('collapses and expands the fields', async () => { + await renderPanel() + + expect(await screen.findByDisplayValue('myws')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ })) + expect(screen.queryByDisplayValue('myws')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ })) + expect(await screen.findByDisplayValue('myws')).toBeTruthy() + }) + + it('autosaves a text field on blur as a one-key partial save', async () => { + await renderPanel() + + const baseUrl = await screen.findByPlaceholderText('https://… (self-hosted)') + fireEvent.change(baseUrl, { target: { value: 'http://localhost:8000' } }) + fireEvent.blur(baseUrl) + + await waitFor(() => + expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { baseUrl: 'http://localhost:8000' }) + ) + expect(saveMemoryProviderConfig).toHaveBeenCalledTimes(1) + }) + + it('does not save on blur when nothing changed', async () => { + await renderPanel() + + const workspace = await screen.findByDisplayValue('myws') + fireEvent.blur(workspace) + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()) + expect(saveMemoryProviderConfig).not.toHaveBeenCalled() + }) + + it('autosaves a committed secret and clears the draft', async () => { + await renderPanel() + + const apiKey = await screen.findByPlaceholderText('Enter Honcho API key') + fireEvent.blur(apiKey) + expect(saveMemoryProviderConfig).not.toHaveBeenCalled() + + fireEvent.change(apiKey, { target: { value: 'hch-new-key' } }) + fireEvent.blur(apiKey) + + await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { apiKey: '[redacted:secret-assignment]' })) + await waitFor(() => expect((apiKey as HTMLInputElement).value).toBe('')) + }) + + it('offers a full-config trigger when modal-only fields exist', async () => { + await renderPanel() + + await screen.findByDisplayValue('myws') + expect(screen.getByRole('button', { name: /Full config/ })).toBeTruthy() + }) + + it('shows an inline error with retry when the load fails, then recovers', async () => { + getMemoryProviderConfig.mockRejectedValueOnce(new Error('Timed out connecting to Hermes backend')) + + await renderPanel() + + expect(await screen.findByText(/Timed out connecting/)).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + + expect(await screen.findByDisplayValue('myws')).toBeTruthy() + }) + + it('renders nothing for a provider with no declared config surface', async () => { + getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] }) + + const { container } = await renderPanel('builtin') + + await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin')) + expect(container.querySelector('section')).toBeNull() + }) +}) -
apps/desktop/src/app/settings/memory/provider-config-panel.tsx@@ -0,0 +1,159 @@ +import { useCallback, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes' +import { SlidersHorizontal } from '@/lib/icons' +import { notifyError } from '@/store/notifications' +import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes' + +import { FieldControl, FieldTitle } from './field-control' +import { ListRow, LoadingState, Pill } from '../primitives' +import { ProviderConfigModal } from './provider-config-modal' + +// Inline fields only: the compact panel must never re-write modal-owned keys. +function seedValues(config: MemoryProviderConfig): Record<string, string> { + return Object.fromEntries( + config.fields.filter(field => field.inline).map(field => [field.key, field.kind === 'secret' ? '' : field.value]) + ) +} + +export function ProviderConfigPanel({ provider }: { provider: string }) { + const [config, setConfig] = useState<MemoryProviderConfig | null>(null) + const [loadError, setLoadError] = useState<null | string>(null) + const [values, setValues] = useState<Record<string, string>>({}) + const [saved, setSaved] = useState<Record<string, string>>({}) + const [expanded, setExpanded] = useState(true) + const [showModal, setShowModal] = useState(false) + + const refresh = useCallback(async () => { + try { + const next = await getMemoryProviderConfig(provider) + const seed = seedValues(next) + setConfig(next) + setValues(seed) + setSaved(seed) + setLoadError(null) + } catch (err) { + setConfig(null) + setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load') + } + }, [provider]) + + useEffect(() => { + setConfig(null) + void refresh() + }, [refresh]) + + // Autosave, matching the settings page around the panel: one-key partial PUT + // on commit, silent on success, no full refresh (it would reset sibling drafts). + const commitField = useCallback( + async (field: MemoryProviderField, value: string) => { + if (value === (saved[field.key] ?? '') || (field.kind === 'secret' && !value.trim())) { + return + } + + try { + await saveMemoryProviderConfig(provider, { [field.key]: value }) + if (field.kind === 'secret') { + setValues(current => ({ ...current, [field.key]: '' })) + setConfig( + current => + current && { + ...current, + fields: current.fields.map(f => (f.key === field.key ? { ...f, is_set: true } : f)) + } + ) + } else { + setSaved(current => ({ ...current, [field.key]: value })) + } + } catch (err) { + notifyError(err, `Failed to save ${field.label}`) + } + }, + [provider, saved] + ) + + // Providers without a declared config surface (e.g. builtin) render nothing. + if (config && config.fields.length === 0) { + return null + } + + if (!config) { + if (loadError) { + return ( + <div className="flex items-center justify-between gap-3 py-2"> + <span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground"> + Memory provider settings failed to load: {loadError} + </span> + <Button onClick={() => void refresh()} size="sm" type="button" variant="secondary"> + Retry + </Button> + </div> + ) + } + return <LoadingState label="Loading memory provider settings..." /> + } + + const inlineFields = config.fields.filter(field => field.inline) + const secretFields = [redacted:secret-assignment] => field.kind === 'secret') + const hasFullConfig = config.fields.some(field => !field.inline) + + return ( + <section className="py-1"> + <div className="flex items-center gap-2 py-2"> + <button + aria-expanded={expanded} + className="flex min-w-0 flex-1 items-center gap-2 text-left" + onClick={() => setExpanded(open => !open)} + type="button" + > + <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> + ))} + </button> + {hasFullConfig && ( + <Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary"> + <SlidersHorizontal className="size-3.5" /> + Full config… + </Button> + )} + </div> + + {expanded && ( + <div className="ml-1.5 border-l-2 border-(--ui-accent-secondary)/25 pb-4 pl-4 pr-4"> + {inlineFields.map(field => ( + <div className="border-b border-border/40 last:border-b-0" key={field.key}> + <ListRow + action={ + <FieldControl + field={field} + onChange={value => setValues(current => ({ ...current, [field.key]: value }))} + onCommit={value => void commitField(field, value)} + value={values[field.key] ?? ''} + /> + } + description={field.description} + title={<FieldTitle field={field} />} + /> + </div> + ))} + </div> + )} + + {hasFullConfig && ( + <ProviderConfigModal + config={config} + onOpenChange={setShowModal} + onSaved={refresh} + open={showModal} + provider={provider} + /> + )} + </section> + ) +} -
/dev/null@@ -1,162 +0,0 @@ -import { act, 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') - - let result: ReturnType<typeof render> - await act(async () => { - result = render(<ProviderConfigPanel provider={provider} />) - }) - - return result! -} - -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() - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) - }) - expect(screen.queryByLabelText('API URL')).toBeNull() - await act(async () => { - 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') - await act(async () => { - 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() - }) -}) -
/dev/null@@ -1,180 +0,0 @@ -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 = [redacted:secret-assignment] => 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> - ) -} -
apps/desktop/src/hermes-profile-scope.test.ts@@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { checkHermesUpdate, getActionStatus, + getMemoryProviderConfig, getStatus, restartGateway, + saveMemoryProviderConfig, setApiRequestProfile, updateHermes } from './hermes' @@ -33,6 +35,17 @@ describe('backend action helpers are profile-scoped', () => { expect(lastProfile()).toBeUndefined() }) + it('forwards the active profile to memory provider config calls', () => { + setApiRequestProfile('coder') + + void getMemoryProviderConfig('honcho') + void saveMemoryProviderConfig('honcho', { workspace: 'w' }) + + for (const call of api.mock.calls) { + expect(call[0].profile).toBe('coder') + } + }) + it('forwards the active profile to every backend action', () => { setApiRequestProfile('coder') -
apps/desktop/src/hermes.ts@@ -519,12 +519,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool // surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> { return window.hermesDesktop.api<MemoryProviderConfig>({ + ...profileScoped(), path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` }) } export function saveMemoryProviderConfig(provider: string, values: Record<string, string>): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, method: 'PUT', body: { values } -
apps/desktop/src/i18n/en.ts@@ -513,6 +513,7 @@ export const en: Translations = { config: { none: 'None', noneParen: '(none)', + builtinOnly: 'Built-in only', notSet: 'Not set', commaSeparated: 'comma-separated values', loading: 'Loading Hermes configuration...', -
apps/desktop/src/i18n/ja.ts@@ -606,6 +606,7 @@ export const ja = defineLocale({ config: { none: 'なし', noneParen: '(なし)', + builtinOnly: '内蔵のみ', notSet: '未設定', commaSeparated: 'カンマ区切りの値', loading: 'Hermes の設定を読み込み中...', -
apps/desktop/src/i18n/types.ts@@ -424,6 +424,7 @@ export interface Translations { config: { none: string noneParen: string + builtinOnly: string notSet: string commaSeparated: string loading: string -
apps/desktop/src/i18n/zh-hant.ts@@ -594,6 +594,7 @@ export const zhHant = defineLocale({ config: { none: '無', noneParen: '(無)', + builtinOnly: '僅內建', notSet: '未設定', commaSeparated: '逗號分隔的值', loading: '正在載入 Hermes 設定...', -
apps/desktop/src/i18n/zh.ts@@ -705,6 +705,7 @@ export const zh: Translations = { config: { none: '无', noneParen: '(无)', + builtinOnly: '仅内置', notSet: '未设置', commaSeparated: '逗号分隔的值', loading: '正在加载 Hermes 配置...', -
apps/desktop/src/types/hermes.ts@@ -120,7 +120,7 @@ export interface EnvVarInfo { url: null | string } -export type MemoryProviderFieldKind = 'secret' | 'select' | 'text' +export type MemoryProviderFieldKind = 'bool' | 'json' | 'number' | 'secret' | 'select' | 'text' export interface MemoryProviderFieldOption { description: string @@ -130,6 +130,9 @@ export interface MemoryProviderFieldOption { export interface MemoryProviderField { description: string + group: string + info?: string + inline: boolean is_set: boolean key: string kind: MemoryProviderFieldKind @@ -140,6 +143,7 @@ export interface MemoryProviderField { } export interface MemoryProviderConfig { + docs_url: string fields: MemoryProviderField[] label: string name: string -
/dev/null@@ -1,149 +0,0 @@ -"""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 = "[redacted:secret-assignment]" - - -@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) -
hermes_cli/web_server.py@@ -76,6 +76,12 @@ write_platform_config_field, _deep_merge, ) +from plugins.memory.config_schema import ( + ProviderConfigSchema, + ProviderField, + STORAGE_HONCHO_HOST_BLOCK, + get_provider_config_schema, +) from gateway.status import ( derive_gateway_busy, derive_gateway_drainable, @@ -85,11 +91,6 @@ parse_active_agents, read_runtime_status, ) -from hermes_cli.memory_providers import ( - MemoryProvider as DeclaredMemoryProvider, - ProviderField as DeclaredProviderField, - get_memory_provider as get_declared_memory_provider, -) from utils import env_var_enabled try: @@ -621,10 +622,13 @@ def _memory_provider_options() -> List[str]: """Discovered memory providers for the ``memory.provider`` select. Directory-scan only (no provider imports), so it's safe at module import - time. ``""`` (built-in) is always first; discovery failures degrade to the - bundled defaults rather than dropping the field. + time. ``""`` (built-in only) is always first; discovery failures degrade to + the bundled defaults rather than dropping the field. The literal + ``builtin`` alias is deliberately NOT offered — built-in memory is not a + provider plugin, and ``_normalize_memory_provider_name`` already maps any + legacy ``builtin``/``built-in``/``none`` value back to ``""`` (#49513). """ - options = ["", "builtin"] + options = [""] try: from plugins.memory import list_memory_provider_names @@ -4453,6 +4457,342 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: return config +# ── Memory provider config: one generic GET/PUT pair, dispatching on storage ── + + +def _provider_field_entry(field: ProviderField) -> Dict[str, Any]: + """Static, storage-independent shape of one field for the UI payload.""" + + return { + "key": field.key, + "label": field.label, + "kind": field.kind, + "description": field.description, + "info": field.info, + "placeholder": field.placeholder, + "inline": field.inline, + "group": field.group, + "options": [ + {"value": opt.value, "label": opt.label, "description": opt.description} + for opt in field.options + ], + } + + +# Sentinel: remove this key so it falls back to the host or built-in default. +_UNSET: Any = object() + + +def _coerce_field_value(field: ProviderField, raw: str) -> Any: + """Coerce a submitted non-secret value to its native JSON type. + + Values arrive as strings over the API; this converts them to the type the + Honcho resolver expects (bool/number/list/dict), so e.g. a boolean is stored + as a JSON ``false`` rather than the string ``"false"`` (which would read as + truthy). Returns ``_UNSET`` when the field should be removed. Raises + ``ValueError`` on malformed input. + """ + + value = (raw or "").strip() + kind = field.kind + + if 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 + + if kind == "bool": + from utils import is_truthy_value + + return is_truthy_value(value) + + if kind == "number": + if not value: + return _UNSET + try: + number = float(value) + except ValueError as exc: + raise ValueError(f"Invalid number for '{field.key}'") from exc + return int(number) if number.is_integer() else number + + if kind == "json": + if not value: + return _UNSET + try: + parsed = json.loads(value) + except (ValueError, TypeError) as exc: + raise ValueError(f"Invalid JSON for '{field.key}'") from exc + if not isinstance(parsed, (dict, list)): + raise ValueError(f"'{field.key}' must be a JSON object or array") + return parsed + + # text / secret — blank clears the key so it falls back to host/default. + return value if value else _UNSET + + +def _serialize_field_value(field: ProviderField, value: Any) -> str: + """Render a stored native value as the string the generic UI edits. + + ``None`` (key absent) yields the field's declared default. Bools become + ``"true"``/``"false"``, JSON objects/arrays are re-encoded, numbers are + stringified — so the renderer's per-kind controls always get the shape they + expect regardless of how the value sits on disk. + """ + + if value is None: + return field.default + if field.kind == "bool": + from utils import is_truthy_value + + return "true" if is_truthy_value(value) else "false" + if field.kind == "json": + if isinstance(value, (dict, list)): + return json.dumps(value) + return str(value) + return str(value) + + +# — flat-json backend (default; reusable for simple providers) — + + +def _flat_json_path(provider: ProviderConfigSchema) -> Path: + return get_hermes_home() / provider.name / "config.json" + + +def _read_flat_json(provider: ProviderConfigSchema) -> Dict[str, Any]: + path = _flat_json_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(field: ProviderField, sources: tuple, env: Dict[str, str]) -> Any: + """Return the stored native value from the first source holding it, or ``None``. + + Presence (``key in source``) decides, not truthiness, so a stored ``False`` + or ``0`` survives instead of being mistaken for "unset". + """ + + for source in sources: + for source_key in (field.key, *field.aliases): + if source_key in source and source[source_key] is not None: + return source[source_key] + for env_key in field.env_fallbacks: + value = env.get(env_key) + if value: + return value + return None + + +def _declared_field_is_set(field: ProviderField, sources: tuple, env: Dict[str, str]) -> bool: + for env_key in (field.env_key, *field.env_fallbacks): + if env_key and env.get(env_key): + return True + return any(source.get(k) for source in sources for k in (field.key, *field.aliases)) + + +# — honcho host-block backend — + + +def _honcho_resolvers(): + """Lazily import the Honcho plugin's resolvers (optional plugin).""" + + from plugins.memory.honcho.client import _host_block, resolve_active_host, resolve_config_path + + return resolve_active_host, resolve_config_path, _host_block + + +def _honcho_read_sources() -> tuple[Dict[str, Any], str, Dict[str, Any]]: + """Return (root config, active host key, host block) for the current profile.""" + + resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers() + host = resolve_active_host() + path = resolve_config_path() + raw: Dict[str, Any] = {} + if path.exists(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + raw = loaded if isinstance(loaded, dict) else {} + except Exception: + _log.warning("Failed to read Honcho config from %s", path, exc_info=True) + return raw, host, host_block_of(raw, host) + + +def _declared_provider_payload(provider: ProviderConfigSchema) -> Dict[str, Any]: + fields: List[Dict[str, Any]] = [] + env = load_env() + is_honcho = provider.storage == STORAGE_HONCHO_HOST_BLOCK + + if is_honcho: + raw, host, host_block = _honcho_read_sources() + + def sources_for(field: ProviderField) -> tuple: + return (host_block, raw) if field.scope == "host" else (raw,) + else: + host = "" + data = _read_flat_json(provider) + + def sources_for(field: ProviderField) -> tuple: + return (data,) + + for field in provider.fields: + entry = _provider_field_entry(field) + sources = sources_for(field) + + if field.is_secret: + entry["value"] = "" # secrets are write-only over the API + entry["is_set"] = _declared_field_is_set(field, sources, env) + fields.append(entry) + continue + + native = _read_field(field, sources, env) + if is_honcho and not field.placeholder and field.key in {"workspace", "aiPeer"}: + # Blank fields surface the resolved host Honcho will actually use. + entry["placeholder"] = host + + value = _serialize_field_value(field, native) + if field.kind == "select" and value not in field.allowed_values(): + value = field.default + entry["value"] = value + # Presence, not truthiness — a stored False/0 is still "set". + entry["is_set"] = native is not None if is_honcho else bool(value) + fields.append(entry) + + return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields} + + +def _apply_field_values(provider: ProviderConfigSchema, values: Dict[str, str], target_for) -> None: + """Apply submitted non-secret fields to their backend dict, in place. + + Only keys present in ``values`` are touched, so a partial save never + clobbers fields owned by another surface. ``_UNSET`` clears the key (and + its aliases) so it falls back to the host/default mapping. + """ + + for field in provider.fields: + if field.is_secret or field.key not in values: + continue + target = target_for(field) + coerced = _coerce_field_value(field, values[field.key]) + if coerced is _UNSET: + target.pop(field.key, None) + for alias in field.aliases: + target.pop(alias, None) + else: + target[field.key] = coerced + + +def _write_provider_flat(provider: ProviderConfigSchema, values: Dict[str, str]) -> None: + from utils import atomic_json_write + + existing = _read_flat_json(provider) + + for field in provider.fields: + if field.is_secret: + submitted = (values.get(field.key) or "").strip() + if submitted and field.env_key: + save_env_value(field.env_key, submitted) + + _apply_field_values(provider, values, lambda field: existing) + + path = _flat_json_path(provider) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_json_write(path, existing, mode=0o600) + + +def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str]) -> None: + """Persist submitted fields to Honcho's real config for the active host. + + Only keys present in ``values`` are touched, so a partial save (e.g. the + inline panel) never clobbers fields owned by the full-config editor. Blank + text clears a key so it falls back to the host/default mapping. + """ + + from plugins.memory.honcho.oauth import ACCESS_TOKEN_PREFIX, _config_refresh_lock + from utils import atomic_json_write + + resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers() + host = resolve_active_host() + # Write the file reads resolve, or a save shadows it with a sparse copy. + path = resolve_config_path() + + # OAuth rotation is single-use; an unlocked RMW here can revoke the grant. + with _config_refresh_lock(path): + cfg: Dict[str, Any] = {} + if path.exists(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + cfg = loaded if isinstance(loaded, dict) else {} + except Exception: + _log.warning("Failed to read Honcho config from %s", path, exc_info=True) + + hosts = cfg.get("hosts") + cfg["hosts"] = hosts = hosts if isinstance(hosts, dict) else {} + # Update the block reads resolve (legacy dot-form included), never shadow it. + existing = host_block_of(cfg, host) + host_key = next((k for k, v in hosts.items() if v is existing), host) if existing else host + host_block = hosts.setdefault(host_key, existing) + + for field in provider.fields: + if not field.is_secret: + continue + submitted = (values.get(field.key) or "").strip() + if not submitted: + continue + if field.env_key: + save_env_value(field.env_key, submitted) + # Persist where the client reads first; an OAuth token owns that slot. + stored = host_block.get(field.key) + if not (isinstance(stored, str) and stored.startswith(ACCESS_TOKEN_PREFIX)): + host_block[field.key] = submitted + + _apply_field_values(provider, values, lambda field: host_block if field.scope == "host" else cfg) + + path.parent.mkdir(parents=True, exist_ok=True) + atomic_json_write(path, cfg, mode=0o600) + + +def _stringify_submitted_values(values: Dict[str, Any]) -> Dict[str, str]: + """The declared-schema path edits strings; the dashboard may send natives.""" + + out: Dict[str, str] = {} + for key, value in values.items(): + if value is None: + out[key] = "" + elif isinstance(value, str): + out[key] = value + elif isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (dict, list)): + out[key] = json.dumps(value) + else: + out[key] = str(value) + return out + + +def _update_memory_provider_config(provider: ProviderConfigSchema, values: Dict[str, str]) -> None: + if provider.storage == STORAGE_HONCHO_HOST_BLOCK: + _write_provider_honcho(provider, values) + else: + _write_provider_flat(provider, values) + + config = load_config() + memory_config = config.get("memory") + if not isinstance(memory_config, dict): + memory_config = {} + config["memory"] = memory_config + if memory_config.get("provider") != provider.name: + memory_config["provider"] = provider.name + save_config(config) + + def _memory_provider_label(name: str) -> str: return name.replace("_", " ").replace("-", " ").title() @@ -5217,136 +5557,28 @@ def _require_valid_memory_provider_name(name: str) -> None: raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") -# --------------------------------------------------------------------------- -# Declared surface — curated desktop schema from hermes_cli.memory_providers. -# The desktop panel requests ?surface=declared; the dashboard keeps the raw -# plugin schema. Providers without a declaration render no desktop panel. -# --------------------------------------------------------------------------- - -def _declared_provider_file_path(provider: DeclaredMemoryProvider) -> Path: - return get_hermes_home() / provider.name / "config.json" - - -def _read_declared_provider_file(provider: DeclaredMemoryProvider) -> Dict[str, Any]: - return _read_json_file(_declared_provider_file_path(provider)) - - -def _declared_read_field_value(field: DeclaredProviderField, data: Dict[str, Any]) -> str: - 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 _declared_field_is_set(field: DeclaredProviderField, data: Dict[str, Any]) -> bool: - 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 _declared_provider_payload(provider: DeclaredMemoryProvider) -> Dict[str, Any]: - data = _read_declared_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"] = _declared_field_is_set(field, data) - else: - value = _declared_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_declared_field_value(field: DeclaredProviderField, raw: str) -> str: - 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 - - -def _update_declared_provider_config(provider: DeclaredMemoryProvider, values: Dict[str, Any]) -> None: - existing = _read_declared_provider_file(provider) - json_values: Dict[str, Any] = {} - secrets: [redacted:secret-assignment] str] = {} - - for field in provider.fields: - if field.is_secret: - submitted = str(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_declared_field_value(field, str(raw)) - - path = _declared_provider_file_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) - - @app.get("/api/memory/providers/{name}/config") -async def get_memory_provider_config(name: str, surface: Optional[str] = None): +async def get_memory_provider_config(name: str, surface: Optional[str] = None, profile: Optional[str] = None): _require_valid_memory_provider_name(name) - if surface == "declared": - declared = get_declared_memory_provider(name) - if declared is None: - # Undeclared providers (e.g. builtin, honcho) have no desktop - # config surface; the generic panel renders nothing. - return {"name": name, "label": name, "fields": []} - return _declared_provider_payload(declared) - - provider = _load_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": [], "setup": _memory_provider_setup_info(name)} - return _memory_provider_payload(name, provider) - + def _run(): + with _profile_scope(profile): + if surface == "declared": + declared = get_provider_config_schema(name) + if declared is None: + # Undeclared providers (e.g. builtin) have no desktop + # config surface; the generic panel renders nothing. + return {"name": name, "label": name, "docs_url": "", "fields": []} + return _declared_provider_payload(declared) + + provider = _load_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": [], "setup": _memory_provider_setup_info(name)} + return _memory_provider_payload(name, provider) + + return await asyncio.to_thread(_run) @app.post("/api/memory/providers/{name}/setup") async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest): @@ -5370,41 +5602,37 @@ async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest): @app.put("/api/memory/providers/{name}/config") -async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None): +async def update_memory_provider_config( + name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None, profile: Optional[str] = None +): _require_valid_memory_provider_name(name) - - if surface == "declared": - declared = get_declared_memory_provider(name) - if declared is None: - raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") - try: - _update_declared_provider_config(declared, body.values or {}) - return {"ok": True} - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except Exception: - _log.exception("PUT /api/memory/providers/%s/config (declared) failed", name) - raise HTTPException(status_code=500, detail="Internal server error") - - provider = _load_memory_provider(name) - if provider is None: - raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") - values = body.values or {} - try: - _write_memory_provider_config_values(name, provider, values) - _require_memory_provider_ready(name) - - config = load_config() - memory_config = config.get("memory") - if not isinstance(memory_config, dict): - memory_config = {} - config["memory"] = memory_config - memory_config["provider"] = name - save_config(config) + def _run(): + with _profile_scope(profile): + if surface == "declared": + declared = get_provider_config_schema(name) + if declared is None: + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") + _update_memory_provider_config(declared, _stringify_submitted_values(values)) + return {"ok": True} + + provider = _load_memory_provider(name) + if provider is None: + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") + _write_memory_provider_config_values(name, provider, values) + _require_memory_provider_ready(name) + config = load_config() + memory_config = config.get("memory") + if not isinstance(memory_config, dict): + memory_config = {} + config["memory"] = memory_config + memory_config["provider"] = name + save_config(config) + return {"ok": True, "active": name} - return {"ok": True, "active": name} + try: + return await asyncio.to_thread(_run) except HTTPException: raise except ValueError as exc: -
plugins/memory/config_schema.py@@ -0,0 +1,144 @@ +"""Declarative configuration schema for memory provider plugins. + +Each memory provider plugin *declares* its configurable surface in a +``config_schema.py`` next to its ``__init__.py`` — 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 provider config surface is pure declaration with no +bespoke UI components. + +Schema files are loaded by path (like the provider plugins themselves), never +via package import: plugin ``__init__.py`` files pull in the agent runtime, +which must not load into the web server. A ``config_schema.py`` may only +import from this module. + +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, dispatching on ``ProviderConfigSchema.storage`` +to the matching backend. +""" + +from __future__ import annotations + +import importlib.util +import logging +from dataclasses import dataclass, field as dataclass_field + +_log = logging.getLogger(__name__) + +# Field kinds understood by the generic renderer. +KIND_TEXT = "text" +KIND_SELECT = "select" +KIND_SECRET = "[redacted:secret-assignment]" +KIND_BOOL = "bool" +KIND_NUMBER = "number" +KIND_JSON = "json" + +# Storage backends understood by web_server (see its read/write dispatch). +STORAGE_FLAT_JSON = "flat_json" +STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block" + + +@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``: + + * non-secret kinds — persisted to the provider's config via its storage + backend 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. ``inline`` + marks the curated subset shown in the compact panel; the rest surface only + in the full-config modal. ``group`` buckets fields within that modal. + """ + + 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, ...] = () + inline: bool = False + group: str = "" + # Longer help text surfaced as an info tooltip next to the field label. + info: str = "" + # Host-block placement: "host" (per-profile) or "root"; flat-json ignores it. + scope: str = "host" + + @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 ProviderConfigSchema: + """A provider plugin's declared config surface.""" + + name: str + label: str + storage: str = STORAGE_FLAT_JSON + # Optional link to the provider's config docs, shown in the full-config modal. + docs_url: str = "" + fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple) + + def inline_fields(self) -> tuple[ProviderField, ...]: + return tuple(f for f in self.fields if f.inline) + + +_SCHEMA_CACHE: dict[str, ProviderConfigSchema] = {} + + +def get_provider_config_schema(name: str) -> ProviderConfigSchema | None: + """Return the ``CONFIG_SCHEMA`` declared by the provider plugin ``name``. + + Providers without a ``config_schema.py`` (e.g. ``builtin``) return ``None`` + and simply render no config panel. The cache keys on the resolved schema + file, not the name: user-installed plugins are per-profile, so one + profile's lookup must never answer for another's. + """ + + from plugins.memory import find_provider_dir + + provider_dir = find_provider_dir(name) + path = provider_dir / "config_schema.py" if provider_dir else None + if path is None or not path.is_file(): + return None + + key = str(path) + if key in _SCHEMA_CACHE: + return _SCHEMA_CACHE[key] + + try: + spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{name}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + schema = getattr(module, "CONFIG_SCHEMA", None) + except Exception: + # Never cache a failed load: it would pin an empty panel until restart. + _log.exception("failed to load config schema for memory provider %r", name) + return None + + if schema is not None: + _SCHEMA_CACHE[key] = schema + return schema -
plugins/memory/hindsight/config_schema.py@@ -0,0 +1,76 @@ +"""Hindsight's declared config surface — rendered by the generic desktop panel.""" + +from plugins.memory.config_schema import ( + KIND_SECRET, + KIND_SELECT, + KIND_TEXT, + ProviderConfigSchema, + ProviderField, + ProviderFieldOption, +) + +CONFIG_SCHEMA = ProviderConfigSchema( + 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", + ), + ), + inline=True, + ), + 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", + inline=True, + ), + ProviderField( + key="api_url", + label="API URL", + kind=KIND_TEXT, + default="https://api.hindsight.vectorize.io", + aliases=("apiUrl",), + env_fallbacks=("HINDSIGHT_API_URL",), + inline=True, + ), + ProviderField( + key="bank_id", + label="Bank ID", + kind=KIND_TEXT, + default="hermes", + aliases=("bankId",), + inline=True, + ), + ProviderField( + key="recall_budget", + label="Recall budget", + kind=KIND_SELECT, + default="mid", + aliases=("budget",), + options=( + ProviderFieldOption("low", "low"), + ProviderFieldOption("mid", "mid"), + ProviderFieldOption("high", "high"), + ), + inline=True, + ), + ), +) -
plugins/memory/honcho/config_schema.py@@ -0,0 +1,324 @@ +"""Honcho's declared config surface — rendered by the generic desktop panel.""" + +from plugins.memory.config_schema import ( + KIND_BOOL, + KIND_JSON, + KIND_NUMBER, + KIND_SECRET, + KIND_SELECT, + KIND_TEXT, + STORAGE_HONCHO_HOST_BLOCK, + ProviderConfigSchema, + ProviderField, + ProviderFieldOption, +) + + +# Reasoning effort levels shared by dialectic-related selects. +_REASONING_LEVELS = ( + ProviderFieldOption("minimal", "Minimal"), + ProviderFieldOption("low", "Low"), + ProviderFieldOption("medium", "Medium"), + ProviderFieldOption("high", "High"), + ProviderFieldOption("max", "Max"), +) + + +CONFIG_SCHEMA = ProviderConfigSchema( + name="honcho", + label="Honcho", + storage=STORAGE_HONCHO_HOST_BLOCK, + docs_url="https://docs.honcho.dev/v3/guides/integrations/hermes", + fields=( + # — Connection — + ProviderField( + key="apiKey", + label="API key", + kind=KIND_SECRET, + env_key="HONCHO_API_KEY", + description="Authenticate with Honcho Cloud. Not needed for a self-hosted base URL.", + placeholder="Enter Honcho API key", + inline=True, + group="Connection", + ), + ProviderField( + key="baseUrl", + label="Base URL", + kind=KIND_TEXT, + aliases=("base_url",), + env_fallbacks=("HONCHO_BASE_URL",), + description="Self-hosted Honcho URL. Overrides the environment when set.", + placeholder="https://… (self-hosted)", + inline=True, + group="Connection", + scope="root", + ), + ProviderField( + key="environment", + label="Environment", + kind=KIND_SELECT, + default="production", + env_fallbacks=("HONCHO_ENVIRONMENT",), + description="Honcho environment. Ignored when a base URL is set.", + options=( + ProviderFieldOption("production", "Cloud"), + ProviderFieldOption("local", "Local"), + ), + inline=True, + group="Connection", + ), + ProviderField( + key="workspace", + label="Workspace", + kind=KIND_TEXT, + description="Honcho workspace ID. Defaults to the profile host.", + inline=True, + group="Connection", + ), + # — Identity — + ProviderField( + key="peerName", + label="Peer name", + kind=KIND_TEXT, + description="Your stable user peer. Unifies memory across platforms for single-user setups.", + placeholder="e.g. eri", + inline=True, + group="Identity", + ), + ProviderField( + key="aiPeer", + label="AI peer", + kind=KIND_TEXT, + description="The AI-side peer name. Defaults to the profile host.", + inline=True, + group="Identity", + ), + # — Session — + ProviderField( + key="sessionStrategy", + label="Session strategy", + kind=KIND_SELECT, + default="per-directory", + description="How conversations map to Honcho sessions.", + info=( + "Per session: every conversation gets its own Honcho session. " + "Per directory: conversations from the same working directory share one. " + "Per repo: conversations from the same git repo share one. " + "Global: everything shares a single session." + ), + options=( + ProviderFieldOption("per-session", "Per session"), + ProviderFieldOption("per-directory", "Per directory"), + ProviderFieldOption("per-repo", "Per repo"), + ProviderFieldOption("global", "Global"), + ), + inline=True, + group="Session", + ), + # —————— Full-config-only fields below (inline=False) —————— + # — Connection — + ProviderField( + key="timeout", + label="Request timeout", + kind=KIND_NUMBER, + aliases=("requestTimeout",), + env_fallbacks=("HONCHO_TIMEOUT",), + description="Request timeout in seconds for Honcho HTTP calls. Blank uses the default.", + placeholder="30", + group="Connection", + scope="root", + ), + # — Identity — + ProviderField( + key="pinUserPeer", + label="Pin user peer", + kind=KIND_BOOL, + default="false", + aliases=("pinPeerName",), + description="Pin the user peer to the peer name, ignoring gateway runtime identity. Unifies memory for single-user setups.", + group="Identity", + ), + ProviderField( + key="runtimePeerPrefix", + label="Runtime peer prefix", + kind=KIND_TEXT, + description="Prefix applied to unknown gateway runtime user IDs.", + placeholder="e.g. telegram_", + group="Identity", + ), + ProviderField( + key="userPeerAliases", + label="User peer aliases", + kind=KIND_JSON, + description="Map gateway runtime user IDs to stable Honcho peers.", + placeholder='{"telegram_123": "eri"}', + group="Identity", + ), + # — Session — + ProviderField( + key="sessionPeerPrefix", + label="Session peer prefix", + kind=KIND_BOOL, + default="false", + description="Prefix session peer names with the host.", + group="Session", + ), + ProviderField( + key="sessions", + label="Session overrides", + kind=KIND_JSON, + description="Explicit session ID overrides keyed by resolver.", + placeholder='{"key": "session-id"}', + group="Session", + scope="root", + ), + # — Message writing — + ProviderField( + key="saveMessages", + label="Save messages", + kind=KIND_BOOL, + default="true", + description="Persist conversation messages to Honcho.", + group="Message writing", + ), + ProviderField( + key="writeFrequency", + label="Write frequency", + kind=KIND_TEXT, + default="async", + description="When to flush messages: async, turn, session, or every N turns.", + info=( + "async: write in the background as messages arrive. " + "turn: flush after each turn. session: flush when the session ends. " + "A number N flushes every N turns." + ), + placeholder="async | turn | session | N", + group="Message writing", + ), + # — Dialectic — + ProviderField( + key="dialecticReasoningLevel", + label="Reasoning level", + kind=KIND_SELECT, + default="low", + description="Reasoning effort for dialectic (peer.chat) calls.", + options=_REASONING_LEVELS, + group="Dialectic", + ), + ProviderField( + key="dialecticDynamic", + label="Dynamic reasoning", + kind=KIND_BOOL, + default="true", + description="Let the model override the reasoning level per call.", + group="Dialectic", + ), + ProviderField( + key="dialecticMaxChars", + label="Max result chars", + kind=KIND_NUMBER, + description="Max chars of dialectic result injected into the system prompt.", + placeholder="1200", + group="Dialectic", + ), + ProviderField( + key="dialecticDepth", + label="Depth", + kind=KIND_NUMBER, + description="Dialectic passes per cycle (1–3).", + placeholder="1", + group="Dialectic", + ), + ProviderField( + key="dialecticDepthLevels", + label="Per-pass levels", + kind=KIND_JSON, + description="Reasoning level per pass; array length matches depth.", + placeholder='["low", "medium"]', + group="Dialectic", + ), + ProviderField( + key="dialecticMaxInputChars", + label="Max input chars", + kind=KIND_NUMBER, + description="Max chars of query input sent to peer.chat().", + placeholder="10000", + group="Dialectic", + ), + # — Reasoning — + ProviderField( + key="reasoningHeuristic", + label="Reasoning heuristic", + kind=KIND_BOOL, + default="true", + description="Scale the reasoning level up on longer queries.", + group="Reasoning", + ), + ProviderField( + key="reasoningLevelCap", + label="Reasoning level cap", + kind=KIND_SELECT, + default="high", + description="Ceiling for the heuristic-selected reasoning level.", + options=_REASONING_LEVELS, + group="Reasoning", + ), + # — Recall — + ProviderField( + key="recallMode", + label="Recall mode", + kind=KIND_SELECT, + default="hybrid", + description="How memory retrieval works: hybrid, context-only, or tools-only.", + info=( + "Hybrid: auto-injected context plus on-demand memory tools. " + "Context only: injection without tools. " + "Tools only: the model queries memory explicitly, nothing is injected." + ), + options=( + ProviderFieldOption("hybrid", "Hybrid"), + ProviderFieldOption("context", "Context only"), + ProviderFieldOption("tools", "Tools only"), + ), + group="Recall", + ), + ProviderField( + key="contextTokens", + label="Context token cap", + kind=KIND_NUMBER, + description="Cap on auto-injected context tokens. Blank leaves it uncapped.", + placeholder="(uncapped)", + group="Recall", + ), + ProviderField( + key="initOnSessionStart", + label="Eager init", + kind=KIND_BOOL, + default="false", + description="Initialize the session eagerly in tools mode instead of on first tool call.", + group="Recall", + ), + # — Limits — + ProviderField( + key="messageMaxChars", + label="Message max chars", + kind=KIND_NUMBER, + description="Max chars per message sent to Honcho.", + placeholder="25000", + group="Limits", + ), + # — Observation — + ProviderField( + key="observationMode", + label="Observation mode", + kind=KIND_SELECT, + default="directional", + description="Per-peer observation preset. Directional observes all directions; unified shares one view.", + options=( + ProviderFieldOption("directional", "Directional"), + ProviderFieldOption("unified", "Unified"), + ), + group="Observation", + ), + ), +) -
tests/hermes_cli/test_web_server.py@@ -491,6 +491,28 @@ def test_get_memory_provider_config_loads_dynamic_plugin_schema(self): assert fields["api_key"]["url"] == "https://app.honcho.dev" assert fields["baseUrl"]["kind"] == "text" + def test_instance_schema_serves_providers_without_declared_schema(self, monkeypatch): + # The default surface serves the plugin instance's get_config_schema(). + from hermes_cli import web_server + + class _Stub: + def get_config_schema(self): + return [ + {"key": "api_key", "description": "Stub API key", "secret": True, "url": "https://stub.example"}, + {"key": "baseUrl", "description": "Stub base URL"}, + ] + + monkeypatch.setattr(web_server, "_load_memory_provider", lambda name: _Stub()) + + resp = self.client.get("/api/memory/providers/mem0/config") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert fields["api_key"]["kind"] == "secret" + assert fields["api_key"]["url"] == "https://stub.example" + assert fields["baseUrl"]["kind"] == "text" + def test_declared_surface_serves_curated_hindsight_schema(self): resp = self.client.get("/api/memory/providers/hindsight/config?surface=declared") @@ -502,7 +524,7 @@ def test_declared_surface_serves_curated_hindsight_schema(self): assert fields["api_key"]["kind"] == "secret" def test_declared_surface_hides_undeclared_providers(self): - resp = self.client.get("/api/memory/providers/honcho/config?surface=declared") + resp = self.client.get("/api/memory/providers/builtin/config?surface=declared") assert resp.status_code == 200 assert resp.json()["fields"] == [] @@ -534,7 +556,7 @@ def test_declared_surface_put_writes_config_and_secret(self): def test_declared_surface_put_rejects_undeclared_provider(self): resp = self.client.put( - "/api/memory/providers/honcho/config?surface=declared", + "/api/memory/providers/builtin/config?surface=declared", json={"values": {"api_key": "x"}}, ) @@ -581,7 +603,9 @@ def test_memory_provider_payloads_include_manifest_setup_hints(self): assert config_resp.status_code == 200 assert config_resp.json()["setup"]["external_dependencies"] == byterover_setup["external_dependencies"] - def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch): + def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch, tmp_path): + # Pin HOME so a developer's real ~/.honcho config can't flip the status. + monkeypatch.setenv("HOME", str(tmp_path)) import hermes_cli.web_server as web_server original_dependency_importable = web_server._dependency_importable @@ -926,6 +950,333 @@ def test_put_moa_models_round_trips_fanout_and_reference_max_tokens(self): fetched = self.client.get("/api/model/moa").json() assert fetched["presets"]["default"]["fanout"] == "user_turn" assert fetched["presets"]["default"]["reference_max_tokens"] == 600 + # ── Memory provider config (Honcho host-block backend) ────────────── + + @pytest.fixture(autouse=True) + def _isolate_honcho_config(self): + # Honcho tests write the suite-wide HERMES_HOME honcho.json; snapshot and + # restore it so provider status/config state never leaks across tests. + from hermes_constants import get_hermes_home + + path = get_hermes_home() / "honcho.json" + before = path.read_bytes() if path.exists() else None + yield + if before is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(before) + + @staticmethod + def _seed_local_honcho(cfg=None): + from hermes_constants import get_hermes_home + + path = get_hermes_home() / "honcho.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(cfg if cfg is not None else {}), encoding="utf-8") + return path + + def test_get_honcho_config_returns_safe_defaults(self, monkeypatch, tmp_path): + # HOME isn't isolated by the suite; pin it so ~/.honcho can't leak in. + monkeypatch.setenv("HOME", str(tmp_path)) + + resp = self.client.get("/api/memory/providers/honcho/config?surface=declared") + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "honcho" + assert data["label"] == "Honcho" + assert data["docs_url"] == "https://docs.honcho.dev/v3/guides/integrations/hermes" + + fields = self._provider_field_map(data) + assert fields["environment"]["kind"] == "select" + assert fields["environment"]["value"] == "production" + assert {opt["value"] for opt in fields["environment"]["options"]} == { + "production", + "local", + } + assert fields["sessionStrategy"]["value"] == "per-directory" + # Blank workspace/aiPeer surface the resolved host as the placeholder. + assert fields["workspace"]["value"] == "" + assert fields["workspace"]["placeholder"] == "hermes" + assert fields["aiPeer"]["placeholder"] == "hermes" + assert fields["apiKey"]["kind"] == "secret" + assert fields["apiKey"]["is_set"] is False + + def test_put_honcho_writes_host_block_root_and_secret(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HONCHO_API_KEY", "guard") + monkeypatch.delenv("HONCHO_API_KEY") + self._seed_local_honcho() + from hermes_constants import get_hermes_home + from hermes_cli.config import load_config, load_env + + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={ + "values": { + "apiKey": "[redacted:secret-assignment]", + "baseUrl": "https://honcho.example.dev", + "environment": "local", + "workspace": "myws", + "peerName": "eri", + "aiPeer": "hermes", + "sessionStrategy": "per-repo", + } + }, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + assert load_config()["memory"]["provider"] == "honcho" + assert load_env()["HONCHO_API_KEY"] == "hch-test-key" + + cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8")) + # baseUrl is root-scoped; the rest live in the active host block. + assert cfg["baseUrl"] == "https://honcho.example.dev" + assert cfg["hosts"]["hermes"]["workspace"] == "myws" + assert cfg["hosts"]["hermes"]["peerName"] == "eri" + assert cfg["hosts"]["hermes"]["environment"] == "local" + assert cfg["hosts"]["hermes"]["sessionStrategy"] == "per-repo" + # The key lands where the client reads first; GET keeps it write-only. + assert cfg["hosts"]["hermes"]["apiKey"] == "hch-test-key" + + def test_put_honcho_blank_text_clears_key(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._seed_local_honcho() + from hermes_constants import get_hermes_home + + self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"workspace": "myws"}}, + ) + self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"workspace": ""}}, + ) + + cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8")) + assert "workspace" not in cfg.get("hosts", {}).get("hermes", {}) + + def test_put_honcho_partial_save_preserves_other_keys(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._seed_local_honcho() + from hermes_constants import get_hermes_home + + self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"workspace": "myws"}}, + ) + self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"peerName": "eri"}}, + ) + + host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"] + assert host["workspace"] == "myws" + assert host["peerName"] == "eri" + + def test_put_honcho_rejects_unsupported_select_value(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"environment": "bogus"}}, + ) + + assert resp.status_code == 400 + + def test_get_honcho_config_does_not_return_secret(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HONCHO_API_KEY", "guard") + monkeypatch.delenv("HONCHO_API_KEY") + self._seed_local_honcho() + + self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"apiKey": "[redacted:secret-assignment]"}}, + ) + + resp = self.client.get("/api/memory/providers/honcho/config?surface=declared") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert fields["apiKey"]["is_set"] is True + assert fields["apiKey"]["value"] == "" + assert "secret-value" not in json.dumps(data) + + def test_put_honcho_bool_stored_natively_and_false_survives(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._seed_local_honcho() + from hermes_constants import get_hermes_home + + self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"saveMessages": "false", "dialecticDynamic": "true"}}, + ) + + host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"] + # Native JSON bools, not the strings "false"/"true" (which read truthy). + assert host["saveMessages"] is False + assert host["dialecticDynamic"] is True + + fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json()) + assert fields["saveMessages"]["value"] == "false" + assert fields["dialecticDynamic"]["value"] == "true" + + def test_put_honcho_number_stored_as_native_number(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._seed_local_honcho() + from hermes_constants import get_hermes_home + + self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"dialecticMaxChars": "1200", "timeout": "2.5"}}, + ) + + cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8")) + assert cfg["hosts"]["hermes"]["dialecticMaxChars"] == 1200 + assert isinstance(cfg["hosts"]["hermes"]["dialecticMaxChars"], int) + # timeout is root-scoped and keeps its fractional part. + assert cfg["timeout"] == 2.5 + + fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json()) + assert fields["dialecticMaxChars"]["value"] == "1200" + + def test_put_honcho_json_round_trips_object(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._seed_local_honcho() + from hermes_constants import get_hermes_home + + self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"userPeerAliases": '{"telegram_1": "eri"}'}}, + ) + + host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"] + assert host["userPeerAliases"] == {"telegram_1": "eri"} + + fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json()) + assert json.loads(fields["userPeerAliases"]["value"]) == {"telegram_1": "eri"} + + def test_put_honcho_first_save_merges_into_resolved_config(self, monkeypatch, tmp_path): + # With no profile-local file, a save merges into the resolved global config. + monkeypatch.setenv("HOME", str(tmp_path)) + from hermes_constants import get_hermes_home + + global_path = tmp_path / ".honcho" / "config.json" + global_path.parent.mkdir(parents=True) + global_path.write_text( + json.dumps({"baseUrl": "https://kept.example", "hosts": {"hermes": {"workspace": "kept"}}}), + encoding="utf-8", + ) + + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"peerName": "eri"}}, + ) + + assert resp.status_code == 200 + assert not (get_hermes_home() / "honcho.json").exists() + cfg = json.loads(global_path.read_text(encoding="utf-8")) + assert cfg["baseUrl"] == "https://kept.example" + assert cfg["hosts"]["hermes"] == {"workspace": "kept", "peerName": "eri"} + + def test_put_honcho_updates_legacy_dot_form_host_block(self, monkeypatch, tmp_path): + # The legacy dot-form block reads resolve is updated in place, not shadowed. + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HONCHO_HOST", "hermes_work") + + path = self._seed_local_honcho({"hosts": {"hermes.work": {"workspace": "w", "peerName": "eri"}}}) + + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"sessionStrategy": "per-repo"}}, + ) + + assert resp.status_code == 200 + hosts = json.loads(path.read_text(encoding="utf-8"))["hosts"] + assert set(hosts) == {"hermes.work"} + assert hosts["hermes.work"] == {"workspace": "w", "peerName": "eri", "sessionStrategy": "per-repo"} + + def test_put_honcho_api_key_never_overwrites_oauth_token(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HONCHO_API_KEY", "guard") + monkeypatch.delenv("HONCHO_API_KEY") + from hermes_cli.config import load_env + + path = self._seed_local_honcho({"hosts": {"hermes": {"apiKey": "[redacted:secret-assignment]"}}}) + + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"apiKey": "[redacted:secret-assignment]"}}, + ) + + assert resp.status_code == 200 + cfg = json.loads(path.read_text(encoding="utf-8")) + # The OAuth grant owns the JSON slot; the manual key lands in the env store. + assert cfg["hosts"]["hermes"]["apiKey"] == "hch-at-oauth-token" + assert load_env()["HONCHO_API_KEY"] == "manual-key" + + def test_put_honcho_tolerates_null_hosts(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + + path = self._seed_local_honcho({"hosts": None}) + + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"workspace": "myws"}}, + ) + + assert resp.status_code == 200 + assert json.loads(path.read_text(encoding="utf-8"))["hosts"]["hermes"]["workspace"] == "myws" + + def test_memory_provider_config_honors_profile_param(self, monkeypatch, tmp_path): + # A ?profile= save must land in that profile's config, not the serving + # process's — same contract as the skills/toolsets endpoints. + monkeypatch.setenv("HOME", str(tmp_path)) + # The suite pins HERMES_HONCHO_HOST=hermes; this test exercises + # profile-driven host resolution, so drop the override explicitly. + monkeypatch.delenv("HERMES_HONCHO_HOST", raising=False) + from hermes_constants import get_hermes_home + from hermes_cli.profiles import get_profile_dir + + self._seed_local_honcho() + + worker_home = get_profile_dir("worker") + worker_home.mkdir(parents=True, exist_ok=True) + worker_cfg = worker_home / "honcho.json" + worker_cfg.write_text(json.dumps({"hosts": {"hermes_worker": {"workspace": "kept"}}}), encoding="utf-8") + + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared&profile=worker", + json={"values": {"peerName": "eri"}}, + ) + + assert resp.status_code == 200 + worker_hosts = json.loads(worker_cfg.read_text(encoding="utf-8"))["hosts"] + host_block = next(iter(worker_hosts.values())) + assert host_block["peerName"] == "eri" + # The serving process's own config is untouched. + own = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8")) + assert "peerName" not in json.dumps(own) + + fields = self._provider_field_map( + self.client.get("/api/memory/providers/honcho/config?surface=declared&profile=worker").json() + ) + assert fields["peerName"]["value"] == "eri" + + def test_put_honcho_rejects_malformed_number_and_json(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + + assert self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"dialecticMaxChars": "lots"}}, + ).status_code == 400 + assert self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"userPeerAliases": "{not json"}}, + ).status_code == 400 # ── GET /api/media (remote image display) ─────────────────────────── @@ -3797,10 +4148,12 @@ def test_memory_provider_field_present_as_select(self): assert entry["type"] == "select" assert entry["category"] == "memory" options = entry["options"] - # Built-in sentinel first, plus at least one discovered provider. + # Built-in-only sentinel first, plus at least one discovered provider. + # The literal "builtin" alias must NOT be offered — built-in memory is + # not a provider plugin (#49513). assert options[0] == "" - assert "builtin" in options - assert len(options) >= 3 + assert "builtin" not in options + assert len(options) >= 2 def test_memory_provider_options_cover_discovered_providers(self): """Every provider the /api/memory endpoint can activate is selectable.""" -
tests/plugins/memory/test_config_schema.py@@ -0,0 +1,67 @@ +"""Tests for config-schema loading from memory provider plugin dirs.""" + +import plugins.memory.config_schema as config_schema +from plugins.memory.config_schema import get_provider_config_schema + + +def test_unknown_provider_is_none(): + assert get_provider_config_schema("builtin") is None + + +def test_plugin_without_schema_is_none(): + # mem0 is a real plugin dir that declares no config_schema.py. + assert get_provider_config_schema("mem0") is None + + +def test_schemas_are_cached_per_provider(): + assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho") + + +def test_cache_keys_on_schema_path_not_name(monkeypatch, tmp_path): + # User-installed plugins are per-profile; two profiles' plugins sharing a + # name must not answer for each other. + import plugins.memory as memory + + schemas = {} + for label in ("A", "B"): + plugin_dir = tmp_path / label / "custom" + plugin_dir.mkdir(parents=True) + (plugin_dir / "config_schema.py").write_text( + "from plugins.memory.config_schema import ProviderConfigSchema\n" + f'CONFIG_SCHEMA = ProviderConfigSchema(name="custom", label="{label}")\n', + encoding="utf-8", + ) + schemas[label] = plugin_dir + + monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {}) + monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["A"]) + assert get_provider_config_schema("custom").label == "A" + + monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["B"]) + assert get_provider_config_schema("custom").label == "B" + + +def test_broken_schema_is_not_cached(monkeypatch, tmp_path): + # A load failure must retry on the next request, not pin an empty panel. + broken_dir = tmp_path / "broken" + broken_dir.mkdir() + schema_file = broken_dir / "config_schema.py" + schema_file.write_text("this is not python(", encoding="utf-8") + + monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {}) + import plugins.memory as memory + + monkeypatch.setattr(memory, "find_provider_dir", lambda name: broken_dir) + + assert get_provider_config_schema("broken") is None + assert not config_schema._SCHEMA_CACHE + + schema_file.write_text( + "from plugins.memory.config_schema import ProviderConfigSchema\n" + 'CONFIG_SCHEMA = ProviderConfigSchema(name="broken", label="Broken")\n', + encoding="utf-8", + ) + + recovered = get_provider_config_schema("broken") + assert recovered is not None + assert recovered.label == "Broken" -
tests/plugins/memory/test_hindsight_config_schema.py@@ -1,14 +1,14 @@ -"""Tests for the declarative memory-provider registry.""" +"""Tests for Hindsight's declared config surface.""" -from hermes_cli.memory_providers import ( +from plugins.memory.config_schema import ( KIND_SECRET, KIND_SELECT, - get_memory_provider, + get_provider_config_schema, ) def test_hindsight_is_declared(): - provider = get_memory_provider("hindsight") + provider = get_provider_config_schema("hindsight") assert provider is not None assert provider.label == "Hindsight" @@ -21,8 +21,17 @@ def test_hindsight_is_declared(): } -def test_hindsight_mode_gating_is_expressed_as_select_options(): - provider = get_memory_provider("hindsight") +def test_fields_are_all_inline(): + provider = get_provider_config_schema("hindsight") + assert provider is not None + + # Hindsight is simple enough to render fully in the compact panel, so it + # never grows a Full config… modal. + assert all(field.inline for field in provider.fields) + + +def test_mode_gating_is_expressed_as_select_options(): + provider = get_provider_config_schema("hindsight") assert provider is not None mode = next(field for field in provider.fields if field.key == "mode") @@ -33,14 +42,10 @@ def test_hindsight_mode_gating_is_expressed_as_select_options(): def test_api_key_is_a_secret_bound_to_env(): - provider = get_memory_provider("hindsight") + provider = get_provider_config_schema("hindsight") assert provider is not None api_key = [redacted:secret-assignment] 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 -
tests/plugins/memory/test_honcho_config_schema.py@@ -0,0 +1,90 @@ +"""Tests for Honcho's declared config surface.""" + +from plugins.memory.config_schema import ( + KIND_BOOL, + KIND_JSON, + KIND_NUMBER, + KIND_SECRET, + KIND_SELECT, + STORAGE_HONCHO_HOST_BLOCK, + get_provider_config_schema, +) + +# The curated set shown in the compact panel; everything else lives in the modal. +INLINE_KEYS = { + "apiKey", + "baseUrl", + "environment", + "workspace", + "peerName", + "aiPeer", + "sessionStrategy", +} + + +def test_honcho_is_declared(): + provider = get_provider_config_schema("honcho") + + assert provider is not None + assert provider.label == "Honcho" + assert provider.storage == STORAGE_HONCHO_HOST_BLOCK + # Field keys are unique, and the curated inline set is present. + keys = [field.key for field in provider.fields] + assert len(keys) == len(set(keys)) + assert INLINE_KEYS <= set(keys) + + +def test_inline_fields_are_the_curated_subset(): + provider = get_provider_config_schema("honcho") + assert provider is not None + + assert {field.key for field in provider.inline_fields()} == INLINE_KEYS + # The modal-only fields are a non-empty remainder. + non_inline = {f.key for f in provider.fields} - INLINE_KEYS + assert {"writeFrequency", "recallMode", "userPeerAliases"} <= non_inline + + +def test_declares_the_new_field_kinds(): + provider = get_provider_config_schema("honcho") + assert provider is not None + + by_key = {f.key: f for f in provider.fields} + assert by_key["saveMessages"].kind == KIND_BOOL + assert by_key["dialecticMaxChars"].kind == KIND_NUMBER + assert by_key["userPeerAliases"].kind == KIND_JSON + assert by_key["recallMode"].allowed_values() == {"hybrid", "context", "tools"} + assert by_key["observationMode"].allowed_values() == {"directional", "unified"} + + +def test_selects_constrain_their_values(): + provider = get_provider_config_schema("honcho") + assert provider is not None + + environment = next(f for f in provider.fields if f.key == "environment") + assert environment.kind == KIND_SELECT + # Honcho SDK only accepts local/production; "demo" is not a valid environment. + assert environment.allowed_values() == {"production", "local"} + + strategy = next(f for f in provider.fields if f.key == "sessionStrategy") + assert strategy.allowed_values() == {"per-directory", "per-repo", "per-session", "global"} + + +def test_api_key_is_a_secret_bound_to_env(): + provider = get_provider_config_schema("honcho") + assert provider is not None + + api_key = [redacted:secret-assignment] for f in provider.fields if f.key == "apiKey") + assert api_key.kind == KIND_SECRET + assert api_key.is_secret is True + assert api_key.env_key == "HONCHO_API_KEY" + + +def test_root_scoped_fields_are_exactly_the_global_ones(): + provider = get_provider_config_schema("honcho") + assert provider is not None + + scopes = {f.key: f.scope for f in provider.fields} + root_keys = {k for k, scope in scopes.items() if scope == "root"} + # baseUrl, timeout and sessions live at the config root in Honcho's schema; + # everything else is per-profile host-scoped. + assert root_keys == {"baseUrl", "timeout", "sessions"}
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#49514 Fix desktop memory provider built-in option · superseded by another PR →
#67206,#49513
Closed issues · 1
- Closed#49513Desktop Memory Provider dropdown treats built-in memory like a provider plugin
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #67206
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 19 Jul 2026