# NousResearch/hermes-agent · Desktop memory provider dropdown and config schema fixes

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #67206. Verify it addresses your case.
- **Recommended:** read PR #67206 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-67206`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #49513: Desktop Memory Provider dropdown treats built-in memory like a provider plugin

## Prior & competing PRs

- **#67206: feat(desktop): declarative memory provider panel + built-in fix (salvage #51020, fixes #49513)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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)
  - fix hunks:
  - `apps/desktop/src/app/settings/config-settings.tsx`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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>
    ```

    ```diff
    @@ -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) ?? []
     
    ```

    ```diff
    @@ -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
                     }
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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', () => {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
     import {
       checkHermesUpdate,
       getActionStatus,
    +  getMemoryProviderConfig,
       getStatus,
       restartGateway,
    +  saveMemoryProviderConfig,
       setApiRequestProfile,
       updateHermes
     } from './hermes'
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -606,6 +606,7 @@ export const ja = defineLocale({
         config: {
           none: 'なし',
           noneParen: '(なし)',
    +      builtinOnly: '内蔵のみ',
           notSet: '未設定',
           commaSeparated: 'カンマ区切りの値',
           loading: 'Hermes の設定を読み込み中...',
    ```
  - `apps/desktop/src/i18n/types.ts`

    ```diff
    @@ -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`

    ```diff
    @@ -594,6 +594,7 @@ export const zhHant = defineLocale({
         config: {
           none: '無',
           noneParen: '(無)',
    +      builtinOnly: '僅內建',
           notSet: '未設定',
           commaSeparated: '逗號分隔的值',
           loading: '正在載入 Hermes 設定...',
    ```
  - `apps/desktop/src/i18n/zh.ts`

    ```diff
    @@ -705,6 +705,7 @@ export const zh: Translations = {
         config: {
           none: '无',
           noneParen: '(无)',
    +      builtinOnly: '仅内置',
           notSet: '未设置',
           commaSeparated: '逗号分隔的值',
           loading: '正在加载 Hermes 配置...',
    ```
  - `apps/desktop/src/types/hermes.ts`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -140,6 +143,7 @@ export interface MemoryProviderField {
     }
     
     export interface MemoryProviderConfig {
    +  docs_url: string
       fields: MemoryProviderField[]
       label: string
       name: string
    ```
  - `/dev/null`

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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()
     
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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")
     
    ```

    ```diff
    @@ -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"] == []
    ```

    ```diff
    @@ -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"}},
             )
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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) ───────────────────────────
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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"}
    ```
- **#49514: Fix desktop memory provider built-in option**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#67206`, `#49513`

## Fix status

- Merged to main in PR #67206 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-19`.
- Subsystem: `apps/desktop`
