NousResearch/hermes-agent

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

0open issues
0open PRs
3items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #67206 feat(desktop): declarative memory provider panel + built-in fix (salvage #51020, fixes #49513)

    merged to main

    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.00 means 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 Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#67206
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
19 Jul 2026

For your agent

The same facts, typed.