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