Merge pull request #67206 from NousResearch/lane/c3-memory-panel

feat(desktop): declarative memory provider panel + built-in fix (salvage #51020, fixes #49513)
This commit is contained in:
Teknium 2026-07-19 03:00:52 -07:00 committed by GitHub
commit 833bae3203
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2395 additions and 691 deletions

View file

@ -18,14 +18,21 @@ import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { PanelEmpty } from '../overlays/panel'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS } from './constants'
import { FallbackModelsField } from './fallback-models-field'
import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import {
enumOptionsFor,
getNested,
isExternalMemoryProvider,
prettyName,
sectionFieldEntries,
setNested
} from './helpers'
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { ProviderConfigPanel } from './provider-config-panel'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
// provider — otherwise every provider's options render at once (the "totally
@ -134,7 +141,9 @@ function ConfigField({
? (optionLabels?.[option] ?? prettyName(option))
: schemaKey === 'display.personality'
? c.none
: c.noneParen}
: schemaKey === 'memory.provider'
? c.builtinOnly
: c.noneParen}
</SelectItem>
))}
</SelectContent>
@ -336,14 +345,12 @@ export function ConfigSettings({
}
const sectionFields = useMemo(() => {
if (!schema) {
if (!schema || !config) {
return new Map<string, [string, ConfigFieldSchema][]>()
}
return new Map(
SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))])
)
}, [schema])
return sectionFieldEntries(schema, config)
}, [schema, config])
const fields = sectionFields.get(activeSectionId) ?? []
@ -459,7 +466,7 @@ export function ConfigSettings({
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
descriptionExtra={
key === 'memory.provider' && Boolean(getNested(config, key)) ? (
key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? (
<MemoryConnect provider={String(getNested(config, key))} />
) : undefined
}
@ -474,8 +481,8 @@ export function ConfigSettings({
schemaKey={key}
value={getNested(config, key)}
/>
{key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? (
<ProviderConfigPanel provider={String(getNested(config, key))} />
{key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? (
<ProviderConfigPanel key={String(getNested(config, key))} provider={String(getNested(config, key))} />
) : null}
</div>
))}

View file

@ -247,7 +247,10 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
// Built-in memory is not a provider plugin: the empty sentinel renders as
// "Built-in only" and a legacy literal `builtin` value is only kept visible
// via enumOptionsFor's current-value passthrough (#49513).
'memory.provider': ['', 'honcho', 'hindsight'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).

View file

@ -3,13 +3,43 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
import {
enumOptionsFor,
getNested,
isExternalMemoryProvider,
providerGroup,
sectionFieldEntries,
setNested,
stripToolsetLabel,
toolsetDisplayLabel
} from './helpers'
describe('settings helpers', () => {
it('lists Hindsight as a built-in desktop memory provider option', () => {
it('lists the desktop memory provider options in their declared order', () => {
const options = enumOptionsFor('memory.provider', '', {})
expect(options).toContain('hindsight')
// Built-in memory is not a provider plugin; the empty sentinel is the
// only built-in-shaped entry (#49513).
expect(options).toEqual(['', 'honcho', 'hindsight'])
})
it('keeps a legacy literal builtin value visible as the current selection', () => {
const options = enumOptionsFor('memory.provider', 'builtin', {})
expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin'])
})
describe('isExternalMemoryProvider', () => {
it('treats only real plugin names as external providers', () => {
expect(isExternalMemoryProvider('honcho')).toBe(true)
expect(isExternalMemoryProvider('hindsight')).toBe(true)
})
it('treats built-in aliases and empty values as not external', () => {
for (const value of ['', 'builtin', 'built-in', 'Builtin', 'none', ' ', undefined, null, 7]) {
expect(isExternalMemoryProvider(value)).toBe(false)
}
})
})
describe('defineFieldCopy', () => {
@ -176,4 +206,39 @@ describe('settings helpers', () => {
expect(opts).toContain('xai')
})
})
describe('sectionFieldEntries', () => {
it('renders memory.provider from config even when the backend schema omits it', () => {
const schema = { 'memory.memory_enabled': { type: 'boolean' as const } }
const config: HermesConfigRecord = { memory: { memory_enabled: true, provider: '' } }
const memoryKeys = (sectionFieldEntries(schema, config).get('memory') ?? []).map(([key]) => key)
expect(memoryKeys).toContain('memory.provider')
})
it('infers the field type from the config value when the schema omits the key', () => {
const config: HermesConfigRecord = { memory: { provider: '', memory_enabled: true, memory_char_limit: 2200 } }
const fields = new Map(sectionFieldEntries({}, config).get('memory') ?? [])
expect(fields.get('memory.provider')?.type).toBe('string')
expect(fields.get('memory.memory_enabled')?.type).toBe('boolean')
expect(fields.get('memory.memory_char_limit')?.type).toBe('number')
})
it('prefers the backend schema entry over inference when both exist', () => {
const schema = { 'memory.provider': { type: 'select' as const, options: ['honcho'] } }
const config: HermesConfigRecord = { memory: { provider: 'honcho' } }
const field = new Map(sectionFieldEntries(schema, config).get('memory') ?? []).get('memory.provider')
expect(field?.type).toBe('select')
expect(field?.options).toEqual(['honcho'])
})
it('hides declared keys absent from both schema and config', () => {
expect(sectionFieldEntries({}, {}).get('memory') ?? []).toHaveLength(0)
})
})
})

View file

@ -1,7 +1,7 @@
import { asText } from '@/lib/text'
import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants'
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants'
// Canonical implementations live in @/lib/text; re-exported here so the many
// settings/capabilities call sites keep their import path.
@ -97,6 +97,40 @@ export function getNested(obj: HermesConfigRecord, path: string): unknown {
return cur
}
export function inferFieldSchema(value: unknown): ConfigFieldSchema {
if (typeof value === 'boolean') {
return { type: 'boolean' }
}
if (typeof value === 'number') {
return { type: 'number' }
}
if (Array.isArray(value)) {
return { type: 'list' }
}
return { type: 'string' }
}
// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal.
export function sectionFieldEntries(
schema: Record<string, ConfigFieldSchema>,
config: HermesConfigRecord
): Map<string, [string, ConfigFieldSchema][]> {
return new Map(
SECTIONS.map(s => [
s.id,
s.keys.flatMap(k => {
const value = getNested(config, k)
const field = schema[k] ?? (value === undefined ? undefined : inferFieldSchema(value))
return field ? [[k, field] as [string, ConfigFieldSchema]] : []
})
])
)
}
export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord {
const clone = structuredClone(obj)
const parts = configPathParts(path)
@ -148,3 +182,16 @@ export function enumOptionsFor(
return current && !opts.includes(current) ? [...opts, current] : opts
}
// Built-in memory (MEMORY.md/USER.md) is controlled by memory_enabled, not
// memory.provider — only a real external plugin name gets provider-shaped
// affordances (config panel, OAuth connect). See #49513.
export function isExternalMemoryProvider(value: unknown): value is string {
if (typeof value !== 'string') {
return false
}
const normalized = value.trim().toLowerCase()
return normalized !== '' && normalized !== 'builtin' && normalized !== 'built-in' && normalized !== 'none'
}

View file

@ -0,0 +1,128 @@
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Check, Info } from '@/lib/icons'
import type { MemoryProviderField } from '@/types/hermes'
import { CONTROL_TEXT } from '../constants'
// Fade the placeholder well below set values so example text never reads as data.
const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45`
// Field label with an optional info tooltip, shared by the panel and modal rows.
export function FieldTitle({ field }: { field: MemoryProviderField }) {
if (!field.info) {
return <>{field.label}</>
}
return (
<span className="inline-flex items-center gap-1.5">
{field.label}
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
<Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" />
</Tip>
</span>
)
}
// Values are edited as strings; the backend coerces them to native types.
export function FieldControl({
field,
value,
onChange,
onCommit
}: {
field: MemoryProviderField
value: string
onChange: (value: string) => void
// Present on autosaving surfaces: discrete controls commit on change, text-like
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
onCommit?: (value: string) => void
}) {
const set = (next: string) => {
onChange(next)
onCommit?.(next)
}
const commitDraft = onCommit ? () => onCommit(value) : undefined
if (field.kind === 'bool') {
return <Switch checked={value === 'true'} onCheckedChange={checked => set(checked ? 'true' : 'false')} />
}
if (field.kind === 'number') {
return (
<Input
className={FIELD_INPUT}
inputMode="numeric"
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
type="number"
value={value}
/>
)
}
if (field.kind === 'json') {
return (
<Textarea
className={FIELD_INPUT}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
spellCheck={false}
value={value}
/>
)
}
if (field.kind === 'select') {
return (
<Select onValueChange={set} value={value}>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (field.kind === 'secret') {
return (
<div className="flex flex-col gap-1">
<Input
className={`w-full ${FIELD_INPUT}`}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
type="password"
value={value}
/>
{field.is_set && (
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
<Check className="size-3 text-(--ui-accent-secondary)" />
set
</span>
)}
</div>
)
}
return (
<Input
className={FIELD_INPUT}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}

View file

@ -0,0 +1,112 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default') }
})
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function field(
overrides: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key' | 'kind'>
): MemoryProviderField {
return {
label: overrides.key,
value: '',
description: '',
placeholder: '',
is_set: false,
inline: false,
group: 'Other',
options: [],
...overrides
}
}
function schema(): MemoryProviderConfig {
return {
name: 'honcho',
label: 'Honcho',
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
fields: [
field({ key: 'workspace', kind: 'text', label: 'Workspace', value: 'myws', inline: true, group: 'Connection' }),
field({ key: 'saveMessages', kind: 'bool', label: 'Save messages', value: 'true', group: 'Message writing' }),
field({ key: 'dialecticMaxChars', kind: 'number', label: 'Max result chars', value: '1200', group: 'Dialectic' }),
field({
key: 'userPeerAliases',
kind: 'json',
label: 'User peer aliases',
value: '{"t":"eri"}',
group: 'Identity'
})
]
}
}
beforeEach(() => {
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderModal(open = true) {
const { ProviderConfigModal } = await import('./provider-config-modal')
const onOpenChange = vi.fn()
const onSaved = vi.fn().mockResolvedValue(undefined)
const result = render(
<ProviderConfigModal
config={schema()}
onOpenChange={onOpenChange}
onSaved={onSaved}
open={open}
provider="honcho"
/>
)
return { ...result, onOpenChange, onSaved }
}
describe('ProviderConfigModal', () => {
it('renders every field grouped, including inline ones, with kind-specific controls', async () => {
await renderModal()
expect(await screen.findByText('Message writing')).toBeTruthy()
expect(screen.getByText('Dialectic')).toBeTruthy()
// bool -> switch, number -> spinbutton, json/text -> textbox
expect(screen.getByRole('switch')).toBeTruthy()
expect(screen.getByDisplayValue('1200')).toBeTruthy()
expect(screen.getByDisplayValue('myws')).toBeTruthy()
expect(screen.getByDisplayValue('{"t":"eri"}')).toBeTruthy()
})
it('saves only edited fields, serializing the toggled bool to "false"', async () => {
const { onSaved, onOpenChange } = await renderModal()
fireEvent.click(await screen.findByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
// A save must never ratify rendered defaults the backend does not store.
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { saveMessages: 'false' }))
await waitFor(() => expect(onSaved).toHaveBeenCalled())
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('renders nothing while closed', async () => {
await renderModal(false)
expect(screen.queryByText('Message writing')).toBeNull()
})
})

View file

@ -0,0 +1,155 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { saveMemoryProviderConfig } from '@/hermes'
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { $activeGatewayProfile } from '@/store/profile'
import { FieldControl, FieldTitle } from './field-control'
import { ListRow } from '../primitives'
// Secrets seed blank: values are write-only and blank keeps the stored one.
function seedAll(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]))
}
// Group fields in declared order, preserving first-seen group sequence.
function groupFields(fields: MemoryProviderField[]): [string, MemoryProviderField[]][] {
const groups: [string, MemoryProviderField[]][] = []
for (const field of fields) {
const name = field.group || 'Other'
const bucket = groups.find(([key]) => key === name)
if (bucket) {
bucket[1].push(field)
} else {
groups.push([name, [field]])
}
}
return groups
}
export function ProviderConfigModal({
config,
provider,
open,
onOpenChange,
onSaved
}: {
config: MemoryProviderConfig
provider: string
open: boolean
onOpenChange: (open: boolean) => void
onSaved: () => Promise<void> | void
}) {
const activeProfile = useStore($activeGatewayProfile)
const [values, setValues] = useState<Record<string, string>>({})
const [seeded, setSeeded] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
// Reseed on open so edits never start from a stale prior-session snapshot.
useEffect(() => {
if (open) {
const seed = seedAll(config)
setSeeded(seed)
setValues(seed)
}
}, [open, config])
const save = async () => {
// Untouched keys stay unsubmitted; runtime defaults still own their values.
const edited = Object.fromEntries(Object.entries(values).filter(([key, value]) => value !== seeded[key]))
setSaving(true)
try {
await saveMemoryProviderConfig(provider, edited)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
await onSaved()
onOpenChange(false)
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-2xl dt-portal-scrollbar">
<DialogHeader>
<DialogTitle icon={SlidersHorizontal}>{config.label} full configuration</DialogTitle>
<DialogDescription>
Every {config.label} option for the <span className="font-medium">{activeProfile}</span> profile. Blank
fields fall back to the resolved host or built-in default.
</DialogDescription>
{config.docs_url && (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
href={config.docs_url}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(config.docs_url)
}}
rel="noreferrer"
target="_blank"
>
{config.label} configuration reference
<ExternalLink className="size-3" />
</a>
)}
</DialogHeader>
<div className="min-w-0">
{groupFields(config.fields).map(([group, fields]) => (
<section className="mt-6 first:mt-2" key={group}>
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
{group}
</h3>
<div className="pl-1">
{fields.map(field => (
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
<ListRow
action={
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
}
description={field.description}
title={<FieldTitle field={field} />}
/>
</div>
))}
</div>
</section>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,208 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig } from '@/types/hermes'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default') }
})
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function honchoSchema(): MemoryProviderConfig {
return {
name: 'honcho',
label: 'Honcho',
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
fields: [
{
key: 'apiKey',
label: 'API key',
kind: 'secret',
value: '',
description: 'Authenticate with Honcho Cloud.',
placeholder: 'Enter Honcho API key',
is_set: false,
inline: true,
group: 'Connection',
options: []
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'text',
value: '',
description: 'Self-hosted Honcho URL.',
placeholder: 'https://… (self-hosted)',
is_set: false,
inline: true,
group: 'Connection',
options: []
},
{
key: 'environment',
label: 'Environment',
kind: 'select',
value: 'production',
description: 'Honcho environment.',
placeholder: '',
is_set: true,
inline: true,
group: 'Connection',
options: [
{ value: 'production', label: 'Production', description: '' },
{ value: 'demo', label: 'Demo', description: '' },
{ value: 'local', label: 'Local', description: '' }
]
},
{
key: 'workspace',
label: 'Workspace',
kind: 'text',
value: 'myws',
description: 'Honcho workspace ID.',
placeholder: 'hermes',
is_set: true,
inline: true,
group: 'Connection',
options: []
},
// Non-inline field: must NOT render in the compact panel and must NOT be
// submitted when the panel saves.
{
key: 'writeFrequency',
label: 'Write frequency',
kind: 'text',
value: 'async',
description: '',
placeholder: '',
is_set: true,
inline: false,
group: 'Message writing',
options: []
}
]
}
}
beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(honchoSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderPanel(provider = 'honcho') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
return render(<ProviderConfigPanel provider={provider} />)
}
describe('ProviderConfigPanel', () => {
it('renders the declared inline fields generically', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
expect(screen.getByPlaceholderText('https://… (self-hosted)')).toBeTruthy()
expect(screen.getByText('Production')).toBeTruthy()
expect(screen.getByText('Self-hosted Honcho URL.')).toBeTruthy()
})
it('hides fields that are not marked inline', async () => {
await renderPanel()
await screen.findByDisplayValue('myws')
expect(screen.queryByDisplayValue('async')).toBeNull()
expect(screen.queryByText('Write frequency')).toBeNull()
})
it('collapses and expands the fields', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
expect(screen.queryByDisplayValue('myws')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
})
it('autosaves a text field on blur as a one-key partial save', async () => {
await renderPanel()
const baseUrl = await screen.findByPlaceholderText('https://… (self-hosted)')
fireEvent.change(baseUrl, { target: { value: 'http://localhost:8000' } })
fireEvent.blur(baseUrl)
await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { baseUrl: 'http://localhost:8000' })
)
expect(saveMemoryProviderConfig).toHaveBeenCalledTimes(1)
})
it('does not save on blur when nothing changed', async () => {
await renderPanel()
const workspace = await screen.findByDisplayValue('myws')
fireEvent.blur(workspace)
await waitFor(() => expect(screen.queryByRole('button', { name: 'Save' })).toBeNull())
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
})
it('autosaves a committed secret and clears the draft', async () => {
await renderPanel()
const apiKey = await screen.findByPlaceholderText('Enter Honcho API key')
fireEvent.blur(apiKey)
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
fireEvent.change(apiKey, { target: { value: 'hch-new-key' } })
fireEvent.blur(apiKey)
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { apiKey: 'hch-new-key' }))
await waitFor(() => expect((apiKey as HTMLInputElement).value).toBe(''))
})
it('offers a full-config trigger when modal-only fields exist', async () => {
await renderPanel()
await screen.findByDisplayValue('myws')
expect(screen.getByRole('button', { name: /Full config/ })).toBeTruthy()
})
it('shows an inline error with retry when the load fails, then recovers', async () => {
getMemoryProviderConfig.mockRejectedValueOnce(new Error('Timed out connecting to Hermes backend'))
await renderPanel()
expect(await screen.findByText(/Timed out connecting/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
})
it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
const { container } = await renderPanel('builtin')
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
})

View file

@ -0,0 +1,159 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { SlidersHorizontal } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { FieldControl, FieldTitle } from './field-control'
import { ListRow, LoadingState, Pill } from '../primitives'
import { ProviderConfigModal } from './provider-config-modal'
// Inline fields only: the compact panel must never re-write modal-owned keys.
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(
config.fields.filter(field => field.inline).map(field => [field.key, field.kind === 'secret' ? '' : field.value])
)
}
export function ProviderConfigPanel({ provider }: { provider: string }) {
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [loadError, setLoadError] = useState<null | string>(null)
const [values, setValues] = useState<Record<string, string>>({})
const [saved, setSaved] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [showModal, setShowModal] = useState(false)
const refresh = useCallback(async () => {
try {
const next = await getMemoryProviderConfig(provider)
const seed = seedValues(next)
setConfig(next)
setValues(seed)
setSaved(seed)
setLoadError(null)
} catch (err) {
setConfig(null)
setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load')
}
}, [provider])
useEffect(() => {
setConfig(null)
void refresh()
}, [refresh])
// Autosave, matching the settings page around the panel: one-key partial PUT
// on commit, silent on success, no full refresh (it would reset sibling drafts).
const commitField = useCallback(
async (field: MemoryProviderField, value: string) => {
if (value === (saved[field.key] ?? '') || (field.kind === 'secret' && !value.trim())) {
return
}
try {
await saveMemoryProviderConfig(provider, { [field.key]: value })
if (field.kind === 'secret') {
setValues(current => ({ ...current, [field.key]: '' }))
setConfig(
current =>
current && {
...current,
fields: current.fields.map(f => (f.key === field.key ? { ...f, is_set: true } : f))
}
)
} else {
setSaved(current => ({ ...current, [field.key]: value }))
}
} catch (err) {
notifyError(err, `Failed to save ${field.label}`)
}
},
[provider, saved]
)
// Providers without a declared config surface (e.g. builtin) render nothing.
if (config && config.fields.length === 0) {
return null
}
if (!config) {
if (loadError) {
return (
<div className="flex items-center justify-between gap-3 py-2">
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Memory provider settings failed to load: {loadError}
</span>
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
Retry
</Button>
</div>
)
}
return <LoadingState label="Loading memory provider settings..." />
}
const inlineFields = config.fields.filter(field => field.inline)
const secretFields = config.fields.filter(field => field.kind === 'secret')
const hasFullConfig = config.fields.some(field => !field.inline)
return (
<section className="py-1">
<div className="flex items-center gap-2 py-2">
<button
aria-expanded={expanded}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => setExpanded(open => !open)}
type="button"
>
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
</span>
{secretFields.map(field => (
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
))}
</button>
{hasFullConfig && (
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
<SlidersHorizontal className="size-3.5" />
Full config
</Button>
)}
</div>
{expanded && (
<div className="ml-1.5 border-l-2 border-(--ui-accent-secondary)/25 pb-4 pl-4 pr-4">
{inlineFields.map(field => (
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
<ListRow
action={
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
onCommit={value => void commitField(field, value)}
value={values[field.key] ?? ''}
/>
}
description={field.description}
title={<FieldTitle field={field} />}
/>
</div>
))}
</div>
)}
{hasFullConfig && (
<ProviderConfigModal
config={config}
onOpenChange={setShowModal}
onSaved={refresh}
open={showModal}
provider={provider}
/>
)}
</section>
)
}

View file

@ -1,162 +0,0 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig } from '@/types/hermes'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
const fields: MemoryProviderConfig['fields'] = [
{
key: 'mode',
label: 'Mode',
kind: 'select',
value: 'cloud',
description: 'How Hermes connects to Hindsight.',
placeholder: '',
is_set: true,
options: [
{ value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' },
{ value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' }
]
},
{
key: 'api_key',
label: 'API key',
kind: 'secret',
value: '',
description: 'Used to authenticate with the Hindsight API.',
placeholder: 'Enter Hindsight API key',
is_set: false,
options: []
},
{
key: 'api_url',
label: 'API URL',
kind: 'text',
value: 'https://api.hindsight.vectorize.io',
description: '',
placeholder: '',
is_set: true,
options: []
},
{
key: 'bank_id',
label: 'Bank ID',
kind: 'text',
value: 'hermes',
description: '',
placeholder: '',
is_set: true,
options: []
},
{
key: 'recall_budget',
label: 'Recall budget',
kind: 'select',
value: 'mid',
description: '',
placeholder: '',
is_set: true,
options: [
{ value: 'low', label: 'low', description: '' },
{ value: 'mid', label: 'mid', description: '' },
{ value: 'high', label: 'high', description: '' }
]
}
]
return {
name: 'hindsight',
label: 'Hindsight',
fields: fields.map((field, index) => ({ ...field, ...overrides[index] }))
}
}
beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(hindsightSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderPanel(provider = 'hindsight') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
let result: ReturnType<typeof render>
await act(async () => {
result = render(<ProviderConfigPanel provider={provider} />)
})
return result!
}
describe('ProviderConfigPanel', () => {
it('renders the declared provider fields generically', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy()
expect(screen.getByDisplayValue('hermes')).toBeTruthy()
expect(screen.getByText('Cloud')).toBeTruthy()
expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0)
expect(screen.getByText('mid')).toBeTruthy()
})
it('collapses and expands the fields', async () => {
await renderPanel()
expect(await screen.findByLabelText('API URL')).toBeTruthy()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
})
expect(screen.queryByLabelText('API URL')).toBeNull()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
})
expect(await screen.findByLabelText('API URL')).toBeTruthy()
})
it('saves edited values without requiring a secret replacement', async () => {
await renderPanel()
const apiUrl = await screen.findByLabelText('API URL')
await act(async () => {
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
})
await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {
mode: 'cloud',
api_key: '',
api_url: 'http://localhost:8888',
bank_id: 'ben-bank',
recall_budget: 'mid'
})
)
})
it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] })
const { container } = await renderPanel('builtin')
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
})

View file

@ -1,180 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { Check, Loader2, Save } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { LoadingState, Pill } from './primitives'
/** Seed editable values from the schema: non-secret fields keep their current
* value, secret fields start blank (their value is never returned). */
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]))
}
function FieldControl({
field,
value,
onChange
}: {
field: MemoryProviderField
value: string
onChange: (value: string) => void
}) {
if (field.kind === 'select') {
const selected = field.options.find(option => option.value === value)
return (
<>
<Select onValueChange={onChange} value={value}>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(selected?.description || field.description) && (
<span className="text-xs text-muted-foreground">{selected?.description || field.description}</span>
)}
</>
)
}
if (field.kind === 'secret') {
return (
<div className="flex flex-wrap items-center gap-2">
<Input
className="min-w-64 flex-1 font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
type="password"
value={value}
/>
{field.is_set && (
<Pill tone="primary">
<Check className="size-3" />
Set
</Pill>
)}
</div>
)
}
return (
<Input
className="font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}
export function ProviderConfigPanel({ provider }: { provider: string }) {
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [values, setValues] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [saving, setSaving] = useState(false)
const refresh = useCallback(async () => {
try {
const next = await getMemoryProviderConfig(provider)
setConfig(next)
setValues(seedValues(next))
} catch (err) {
notifyError(err, 'Memory provider settings failed to load')
setConfig(null)
}
}, [provider])
useEffect(() => {
setConfig(null)
void refresh()
}, [refresh])
const save = useCallback(async () => {
if (!config) {
return
}
setSaving(true)
try {
await saveMemoryProviderConfig(provider, values)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
await refresh()
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
} finally {
setSaving(false)
}
}, [config, provider, refresh, values])
// Providers without a declared config surface (e.g. builtin) render nothing.
if (config && config.fields.length === 0) {
return null
}
if (!config) {
return <LoadingState label="Loading memory provider settings..." />
}
const secretFields = config.fields.filter(field => field.kind === 'secret')
return (
<section className="py-3">
<button
aria-expanded={expanded}
className="flex w-full items-center justify-between gap-3 rounded-lg bg-background/60 px-3 py-2 text-left hover:bg-accent/50"
onClick={() => setExpanded(open => !open)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
</span>
{secretFields.map(field => (
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
))}
</span>
</button>
{expanded && (
<div className="mt-3 grid gap-4 rounded-xl bg-background/60 p-4">
{config.fields.map(field => (
<label className="grid gap-1.5" key={field.key}>
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
{field.kind !== 'select' && field.description && (
<span className="text-xs text-muted-foreground">{field.description}</span>
)}
</label>
))}
<div className="flex justify-end">
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
</div>
</div>
)}
</section>
)
}

View file

@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
checkHermesUpdate,
getActionStatus,
getMemoryProviderConfig,
getStatus,
restartGateway,
saveMemoryProviderConfig,
setApiRequestProfile,
updateHermes
} from './hermes'
@ -33,6 +35,17 @@ describe('backend action helpers are profile-scoped', () => {
expect(lastProfile()).toBeUndefined()
})
it('forwards the active profile to memory provider config calls', () => {
setApiRequestProfile('coder')
void getMemoryProviderConfig('honcho')
void saveMemoryProviderConfig('honcho', { workspace: 'w' })
for (const call of api.mock.calls) {
expect(call[0].profile).toBe('coder')
}
})
it('forwards the active profile to every backend action', () => {
setApiRequestProfile('coder')

View file

@ -576,12 +576,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema.
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
})
}
export function saveMemoryProviderConfig(provider: string, values: Record<string, string>): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
method: 'PUT',
body: { values }

View file

@ -513,6 +513,7 @@ export const en: Translations = {
config: {
none: 'None',
noneParen: '(none)',
builtinOnly: 'Built-in only',
notSet: 'Not set',
commaSeparated: 'comma-separated values',
loading: 'Loading Hermes configuration...',

View file

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

View file

@ -424,6 +424,7 @@ export interface Translations {
config: {
none: string
noneParen: string
builtinOnly: string
notSet: string
commaSeparated: string
loading: string

View file

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

View file

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

View file

@ -120,7 +120,7 @@ export interface EnvVarInfo {
url: null | string
}
export type MemoryProviderFieldKind = 'secret' | 'select' | 'text'
export type MemoryProviderFieldKind = 'bool' | 'json' | 'number' | 'secret' | 'select' | 'text'
export interface MemoryProviderFieldOption {
description: string
@ -130,6 +130,9 @@ export interface MemoryProviderFieldOption {
export interface MemoryProviderField {
description: string
group: string
info?: string
inline: boolean
is_set: boolean
key: string
kind: MemoryProviderFieldKind
@ -140,6 +143,7 @@ export interface MemoryProviderField {
}
export interface MemoryProviderConfig {
docs_url: string
fields: MemoryProviderField[]
label: string
name: string

View file

@ -1,149 +0,0 @@
"""Declarative configuration schema for desktop memory providers.
Each memory provider *declares* its configurable surface here the fields, their
types, which values are secrets, and (for selects) the allowed options. A single
generic renderer in the desktop UI and a single generic ``GET/PUT
/api/memory/providers/{name}/config`` endpoint pair drive the whole experience,
so adding a new provider (mem0, honcho, ...) is pure declaration with zero
bespoke UI components or endpoints.
This module is intentionally pure data: it imports nothing from the config/env
layer. ``web_server`` owns the generic read/write logic that interprets these
declarations against config.yaml, the provider config file, and the env store.
"""
from __future__ import annotations
from dataclasses import dataclass, field as dataclass_field
# Field kinds understood by the generic renderer.
KIND_TEXT = "text"
KIND_SELECT = "select"
KIND_SECRET = "secret"
@dataclass(frozen=True)
class ProviderFieldOption:
"""A single choice for a ``select`` field."""
value: str
label: str
description: str = ""
@dataclass(frozen=True)
class ProviderField:
"""One configurable field on a memory provider.
A field is stored in exactly one place, decided by ``kind``:
* ``text`` / ``select`` persisted to the provider's JSON config file
(``<hermes_home>/<provider>/config.json``) under ``key``.
* ``secret`` persisted to the env store under ``env_key`` and never read
back out over the API (only an ``is_set`` flag is surfaced).
``aliases`` and ``env_fallbacks`` let a field read legacy values written by
earlier CLI/env setup without re-introducing per-provider code.
"""
key: str
label: str
kind: str = KIND_TEXT
default: str = ""
description: str = ""
placeholder: str = ""
options: tuple[ProviderFieldOption, ...] = ()
env_key: str | None = None
aliases: tuple[str, ...] = ()
env_fallbacks: tuple[str, ...] = ()
@property
def is_secret(self) -> bool:
return self.kind == KIND_SECRET
def allowed_values(self) -> set[str]:
return {opt.value for opt in self.options}
@dataclass(frozen=True)
class MemoryProvider:
"""A declared memory provider and its configurable fields."""
name: str
label: str
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
HINDSIGHT = MemoryProvider(
name="hindsight",
label="Hindsight",
fields=(
ProviderField(
key="mode",
label="Mode",
kind=KIND_SELECT,
default="cloud",
description="How Hermes connects to Hindsight.",
options=(
ProviderFieldOption(
"cloud",
"Cloud",
"Hindsight Cloud API (lightweight, just needs an API key)",
),
ProviderFieldOption(
"local_external",
"Local External",
"Connect to an existing Hindsight instance",
),
),
),
ProviderField(
key="api_key",
label="API key",
kind=KIND_SECRET,
env_key="HINDSIGHT_API_KEY",
description="Used to authenticate with the Hindsight API.",
placeholder="Enter Hindsight API key",
),
ProviderField(
key="api_url",
label="API URL",
kind=KIND_TEXT,
default="https://api.hindsight.vectorize.io",
aliases=("apiUrl",),
env_fallbacks=("HINDSIGHT_API_URL",),
),
ProviderField(
key="bank_id",
label="Bank ID",
kind=KIND_TEXT,
default="hermes",
aliases=("bankId",),
),
ProviderField(
key="recall_budget",
label="Recall budget",
kind=KIND_SELECT,
default="mid",
aliases=("budget",),
options=(
ProviderFieldOption("low", "low"),
ProviderFieldOption("mid", "mid"),
ProviderFieldOption("high", "high"),
),
),
),
)
# Registry of providers that expose a desktop config surface. Providers without
# an entry here (e.g. ``builtin``) simply render no config panel.
MEMORY_PROVIDERS: dict[str, MemoryProvider] = {
HINDSIGHT.name: HINDSIGHT,
}
def get_memory_provider(name: str) -> MemoryProvider | None:
"""Return the declared provider for ``name``, or ``None`` if undeclared."""
return MEMORY_PROVIDERS.get(name)

View file

@ -76,6 +76,12 @@ from hermes_cli.config import (
write_platform_config_field,
_deep_merge,
)
from plugins.memory.config_schema import (
ProviderConfigSchema,
ProviderField,
STORAGE_HONCHO_HOST_BLOCK,
get_provider_config_schema,
)
from gateway.status import (
derive_gateway_busy,
derive_gateway_drainable,
@ -85,11 +91,6 @@ from gateway.status import (
parse_active_agents,
read_runtime_status,
)
from hermes_cli.memory_providers import (
MemoryProvider as DeclaredMemoryProvider,
ProviderField as DeclaredProviderField,
get_memory_provider as get_declared_memory_provider,
)
from utils import env_var_enabled
try:
@ -621,10 +622,13 @@ def _memory_provider_options() -> List[str]:
"""Discovered memory providers for the ``memory.provider`` select.
Directory-scan only (no provider imports), so it's safe at module import
time. ``""`` (built-in) is always first; discovery failures degrade to the
bundled defaults rather than dropping the field.
time. ``""`` (built-in only) is always first; discovery failures degrade to
the bundled defaults rather than dropping the field. The literal
``builtin`` alias is deliberately NOT offered built-in memory is not a
provider plugin, and ``_normalize_memory_provider_name`` already maps any
legacy ``builtin``/``built-in``/``none`` value back to ``""`` (#49513).
"""
options = ["", "builtin"]
options = [""]
try:
from plugins.memory import list_memory_provider_names
@ -4586,6 +4590,342 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
return config
# ── Memory provider config: one generic GET/PUT pair, dispatching on storage ──
def _provider_field_entry(field: ProviderField) -> Dict[str, Any]:
"""Static, storage-independent shape of one field for the UI payload."""
return {
"key": field.key,
"label": field.label,
"kind": field.kind,
"description": field.description,
"info": field.info,
"placeholder": field.placeholder,
"inline": field.inline,
"group": field.group,
"options": [
{"value": opt.value, "label": opt.label, "description": opt.description}
for opt in field.options
],
}
# Sentinel: remove this key so it falls back to the host or built-in default.
_UNSET: Any = object()
def _coerce_field_value(field: ProviderField, raw: str) -> Any:
"""Coerce a submitted non-secret value to its native JSON type.
Values arrive as strings over the API; this converts them to the type the
Honcho resolver expects (bool/number/list/dict), so e.g. a boolean is stored
as a JSON ``false`` rather than the string ``"false"`` (which would read as
truthy). Returns ``_UNSET`` when the field should be removed. Raises
``ValueError`` on malformed input.
"""
value = (raw or "").strip()
kind = field.kind
if kind == "select":
if not value:
value = field.default
if value not in field.allowed_values():
raise ValueError(f"Invalid value for '{field.key}'")
return value
if kind == "bool":
from utils import is_truthy_value
return is_truthy_value(value)
if kind == "number":
if not value:
return _UNSET
try:
number = float(value)
except ValueError as exc:
raise ValueError(f"Invalid number for '{field.key}'") from exc
return int(number) if number.is_integer() else number
if kind == "json":
if not value:
return _UNSET
try:
parsed = json.loads(value)
except (ValueError, TypeError) as exc:
raise ValueError(f"Invalid JSON for '{field.key}'") from exc
if not isinstance(parsed, (dict, list)):
raise ValueError(f"'{field.key}' must be a JSON object or array")
return parsed
# text / secret — blank clears the key so it falls back to host/default.
return value if value else _UNSET
def _serialize_field_value(field: ProviderField, value: Any) -> str:
"""Render a stored native value as the string the generic UI edits.
``None`` (key absent) yields the field's declared default. Bools become
``"true"``/``"false"``, JSON objects/arrays are re-encoded, numbers are
stringified so the renderer's per-kind controls always get the shape they
expect regardless of how the value sits on disk.
"""
if value is None:
return field.default
if field.kind == "bool":
from utils import is_truthy_value
return "true" if is_truthy_value(value) else "false"
if field.kind == "json":
if isinstance(value, (dict, list)):
return json.dumps(value)
return str(value)
return str(value)
# — flat-json backend (default; reusable for simple providers) —
def _flat_json_path(provider: ProviderConfigSchema) -> Path:
return get_hermes_home() / provider.name / "config.json"
def _read_flat_json(provider: ProviderConfigSchema) -> Dict[str, Any]:
path = _flat_json_path(provider)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
_log.warning("Failed to read memory provider config from %s", path, exc_info=True)
return {}
return data if isinstance(data, dict) else {}
def _read_field(field: ProviderField, sources: tuple, env: Dict[str, str]) -> Any:
"""Return the stored native value from the first source holding it, or ``None``.
Presence (``key in source``) decides, not truthiness, so a stored ``False``
or ``0`` survives instead of being mistaken for "unset".
"""
for source in sources:
for source_key in (field.key, *field.aliases):
if source_key in source and source[source_key] is not None:
return source[source_key]
for env_key in field.env_fallbacks:
value = env.get(env_key)
if value:
return value
return None
def _declared_field_is_set(field: ProviderField, sources: tuple, env: Dict[str, str]) -> bool:
for env_key in (field.env_key, *field.env_fallbacks):
if env_key and env.get(env_key):
return True
return any(source.get(k) for source in sources for k in (field.key, *field.aliases))
# — honcho host-block backend —
def _honcho_resolvers():
"""Lazily import the Honcho plugin's resolvers (optional plugin)."""
from plugins.memory.honcho.client import _host_block, resolve_active_host, resolve_config_path
return resolve_active_host, resolve_config_path, _host_block
def _honcho_read_sources() -> tuple[Dict[str, Any], str, Dict[str, Any]]:
"""Return (root config, active host key, host block) for the current profile."""
resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers()
host = resolve_active_host()
path = resolve_config_path()
raw: Dict[str, Any] = {}
if path.exists():
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
raw = loaded if isinstance(loaded, dict) else {}
except Exception:
_log.warning("Failed to read Honcho config from %s", path, exc_info=True)
return raw, host, host_block_of(raw, host)
def _declared_provider_payload(provider: ProviderConfigSchema) -> Dict[str, Any]:
fields: List[Dict[str, Any]] = []
env = load_env()
is_honcho = provider.storage == STORAGE_HONCHO_HOST_BLOCK
if is_honcho:
raw, host, host_block = _honcho_read_sources()
def sources_for(field: ProviderField) -> tuple:
return (host_block, raw) if field.scope == "host" else (raw,)
else:
host = ""
data = _read_flat_json(provider)
def sources_for(field: ProviderField) -> tuple:
return (data,)
for field in provider.fields:
entry = _provider_field_entry(field)
sources = sources_for(field)
if field.is_secret:
entry["value"] = "" # secrets are write-only over the API
entry["is_set"] = _declared_field_is_set(field, sources, env)
fields.append(entry)
continue
native = _read_field(field, sources, env)
if is_honcho and not field.placeholder and field.key in {"workspace", "aiPeer"}:
# Blank fields surface the resolved host Honcho will actually use.
entry["placeholder"] = host
value = _serialize_field_value(field, native)
if field.kind == "select" and value not in field.allowed_values():
value = field.default
entry["value"] = value
# Presence, not truthiness — a stored False/0 is still "set".
entry["is_set"] = native is not None if is_honcho else bool(value)
fields.append(entry)
return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields}
def _apply_field_values(provider: ProviderConfigSchema, values: Dict[str, str], target_for) -> None:
"""Apply submitted non-secret fields to their backend dict, in place.
Only keys present in ``values`` are touched, so a partial save never
clobbers fields owned by another surface. ``_UNSET`` clears the key (and
its aliases) so it falls back to the host/default mapping.
"""
for field in provider.fields:
if field.is_secret or field.key not in values:
continue
target = target_for(field)
coerced = _coerce_field_value(field, values[field.key])
if coerced is _UNSET:
target.pop(field.key, None)
for alias in field.aliases:
target.pop(alias, None)
else:
target[field.key] = coerced
def _write_provider_flat(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
from utils import atomic_json_write
existing = _read_flat_json(provider)
for field in provider.fields:
if field.is_secret:
submitted = (values.get(field.key) or "").strip()
if submitted and field.env_key:
save_env_value(field.env_key, submitted)
_apply_field_values(provider, values, lambda field: existing)
path = _flat_json_path(provider)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(path, existing, mode=0o600)
def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
"""Persist submitted fields to Honcho's real config for the active host.
Only keys present in ``values`` are touched, so a partial save (e.g. the
inline panel) never clobbers fields owned by the full-config editor. Blank
text clears a key so it falls back to the host/default mapping.
"""
from plugins.memory.honcho.oauth import ACCESS_TOKEN_PREFIX, _config_refresh_lock
from utils import atomic_json_write
resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers()
host = resolve_active_host()
# Write the file reads resolve, or a save shadows it with a sparse copy.
path = resolve_config_path()
# OAuth rotation is single-use; an unlocked RMW here can revoke the grant.
with _config_refresh_lock(path):
cfg: Dict[str, Any] = {}
if path.exists():
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
cfg = loaded if isinstance(loaded, dict) else {}
except Exception:
_log.warning("Failed to read Honcho config from %s", path, exc_info=True)
hosts = cfg.get("hosts")
cfg["hosts"] = hosts = hosts if isinstance(hosts, dict) else {}
# Update the block reads resolve (legacy dot-form included), never shadow it.
existing = host_block_of(cfg, host)
host_key = next((k for k, v in hosts.items() if v is existing), host) if existing else host
host_block = hosts.setdefault(host_key, existing)
for field in provider.fields:
if not field.is_secret:
continue
submitted = (values.get(field.key) or "").strip()
if not submitted:
continue
if field.env_key:
save_env_value(field.env_key, submitted)
# Persist where the client reads first; an OAuth token owns that slot.
stored = host_block.get(field.key)
if not (isinstance(stored, str) and stored.startswith(ACCESS_TOKEN_PREFIX)):
host_block[field.key] = submitted
_apply_field_values(provider, values, lambda field: host_block if field.scope == "host" else cfg)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(path, cfg, mode=0o600)
def _stringify_submitted_values(values: Dict[str, Any]) -> Dict[str, str]:
"""The declared-schema path edits strings; the dashboard may send natives."""
out: Dict[str, str] = {}
for key, value in values.items():
if value is None:
out[key] = ""
elif isinstance(value, str):
out[key] = value
elif isinstance(value, bool):
out[key] = "true" if value else "false"
elif isinstance(value, (dict, list)):
out[key] = json.dumps(value)
else:
out[key] = str(value)
return out
def _update_memory_provider_config(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
if provider.storage == STORAGE_HONCHO_HOST_BLOCK:
_write_provider_honcho(provider, values)
else:
_write_provider_flat(provider, values)
config = load_config()
memory_config = config.get("memory")
if not isinstance(memory_config, dict):
memory_config = {}
config["memory"] = memory_config
if memory_config.get("provider") != provider.name:
memory_config["provider"] = provider.name
save_config(config)
def _memory_provider_label(name: str) -> str:
return name.replace("_", " ").replace("-", " ").title()
@ -5350,136 +5690,28 @@ def _require_valid_memory_provider_name(name: str) -> None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
# ---------------------------------------------------------------------------
# Declared surface — curated desktop schema from hermes_cli.memory_providers.
# The desktop panel requests ?surface=declared; the dashboard keeps the raw
# plugin schema. Providers without a declaration render no desktop panel.
# ---------------------------------------------------------------------------
def _declared_provider_file_path(provider: DeclaredMemoryProvider) -> Path:
return get_hermes_home() / provider.name / "config.json"
def _read_declared_provider_file(provider: DeclaredMemoryProvider) -> Dict[str, Any]:
return _read_json_file(_declared_provider_file_path(provider))
def _declared_read_field_value(field: DeclaredProviderField, data: Dict[str, Any]) -> str:
for source_key in (field.key, *field.aliases):
value = data.get(source_key)
if value:
return str(value)
env_on_disk = load_env()
for env_key in field.env_fallbacks:
value = env_on_disk.get(env_key)
if value:
return str(value)
return field.default
def _declared_field_is_set(field: DeclaredProviderField, data: Dict[str, Any]) -> bool:
env_on_disk = load_env()
for env_key in (field.env_key, *field.env_fallbacks):
if env_key and env_on_disk.get(env_key):
return True
return any(data.get(source_key) for source_key in (field.key, *field.aliases))
def _declared_provider_payload(provider: DeclaredMemoryProvider) -> Dict[str, Any]:
data = _read_declared_provider_file(provider)
fields: List[Dict[str, Any]] = []
for field in provider.fields:
entry: Dict[str, Any] = {
"key": field.key,
"label": field.label,
"kind": field.kind,
"description": field.description,
"placeholder": field.placeholder,
"options": [
{"value": opt.value, "label": opt.label, "description": opt.description}
for opt in field.options
],
}
if field.is_secret:
# Secrets are write-only over the API; only expose whether one is set.
entry["value"] = ""
entry["is_set"] = _declared_field_is_set(field, data)
else:
value = _declared_read_field_value(field, data)
if field.kind == "select" and value not in field.allowed_values():
value = field.default
entry["value"] = value
entry["is_set"] = bool(value)
fields.append(entry)
return {"name": provider.name, "label": provider.label, "fields": fields}
def _coerce_declared_field_value(field: DeclaredProviderField, raw: str) -> str:
value = (raw or "").strip()
if field.kind == "select":
if not value:
value = field.default
if value not in field.allowed_values():
raise ValueError(f"Invalid value for '{field.key}'")
return value
return value or field.default
def _update_declared_provider_config(provider: DeclaredMemoryProvider, values: Dict[str, Any]) -> None:
existing = _read_declared_provider_file(provider)
json_values: Dict[str, Any] = {}
secrets: Dict[str, str] = {}
for field in provider.fields:
if field.is_secret:
submitted = str(values.get(field.key) or "").strip()
if submitted and field.env_key:
secrets[field.env_key] = submitted
continue
raw = (
values[field.key]
if field.key in values
else str(existing.get(field.key, field.default))
)
json_values[field.key] = _coerce_declared_field_value(field, str(raw))
path = _declared_provider_file_path(provider)
path.parent.mkdir(parents=True, exist_ok=True)
existing.update(json_values)
from utils import atomic_json_write
atomic_json_write(path, existing, mode=0o600)
for env_key, secret in secrets.items():
save_env_value(env_key, secret)
@app.get("/api/memory/providers/{name}/config")
async def get_memory_provider_config(name: str, surface: Optional[str] = None):
async def get_memory_provider_config(name: str, surface: Optional[str] = None, profile: Optional[str] = None):
_require_valid_memory_provider_name(name)
if surface == "declared":
declared = get_declared_memory_provider(name)
if declared is None:
# Undeclared providers (e.g. builtin, honcho) have no desktop
# config surface; the generic panel renders nothing.
return {"name": name, "label": name, "fields": []}
return _declared_provider_payload(declared)
def _run():
with _profile_scope(profile):
if surface == "declared":
declared = get_provider_config_schema(name)
if declared is None:
# Undeclared providers (e.g. builtin) have no desktop
# config surface; the generic panel renders nothing.
return {"name": name, "label": name, "docs_url": "", "fields": []}
return _declared_provider_payload(declared)
provider = _load_memory_provider(name)
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return an
# empty schema so the generic panel simply renders nothing.
return {"name": name, "label": name, "fields": [], "setup": _memory_provider_setup_info(name)}
return _memory_provider_payload(name, provider)
provider = _load_memory_provider(name)
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return an
# empty schema so the generic panel simply renders nothing.
return {"name": name, "label": name, "fields": [], "setup": _memory_provider_setup_info(name)}
return _memory_provider_payload(name, provider)
return await asyncio.to_thread(_run)
@app.post("/api/memory/providers/{name}/setup")
async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest):
@ -5503,41 +5735,37 @@ async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest):
@app.put("/api/memory/providers/{name}/config")
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None):
async def update_memory_provider_config(
name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None, profile: Optional[str] = None
):
_require_valid_memory_provider_name(name)
if surface == "declared":
declared = get_declared_memory_provider(name)
if declared is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
try:
_update_declared_provider_config(declared, body.values or {})
return {"ok": True}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception:
_log.exception("PUT /api/memory/providers/%s/config (declared) failed", name)
raise HTTPException(status_code=500, detail="Internal server error")
provider = _load_memory_provider(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
values = body.values or {}
def _run():
with _profile_scope(profile):
if surface == "declared":
declared = get_provider_config_schema(name)
if declared is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
_update_memory_provider_config(declared, _stringify_submitted_values(values))
return {"ok": True}
provider = _load_memory_provider(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
_write_memory_provider_config_values(name, provider, values)
_require_memory_provider_ready(name)
config = load_config()
memory_config = config.get("memory")
if not isinstance(memory_config, dict):
memory_config = {}
config["memory"] = memory_config
memory_config["provider"] = name
save_config(config)
return {"ok": True, "active": name}
try:
_write_memory_provider_config_values(name, provider, values)
_require_memory_provider_ready(name)
config = load_config()
memory_config = config.get("memory")
if not isinstance(memory_config, dict):
memory_config = {}
config["memory"] = memory_config
memory_config["provider"] = name
save_config(config)
return {"ok": True, "active": name}
return await asyncio.to_thread(_run)
except HTTPException:
raise
except ValueError as exc:

View file

@ -0,0 +1,144 @@
"""Declarative configuration schema for memory provider plugins.
Each memory provider plugin *declares* its configurable surface in a
``config_schema.py`` next to its ``__init__.py`` the fields, their types,
which values are secrets, and (for selects) the allowed options. A single
generic renderer in the desktop UI and a single generic ``GET/PUT
/api/memory/providers/{name}/config`` endpoint pair drive the whole
experience, so adding a provider config surface is pure declaration with no
bespoke UI components.
Schema files are loaded by path (like the provider plugins themselves), never
via package import: plugin ``__init__.py`` files pull in the agent runtime,
which must not load into the web server. A ``config_schema.py`` may only
import from this module.
This module is intentionally pure data: it imports nothing from the
config/env layer. ``web_server`` owns the generic read/write logic that
interprets these declarations, dispatching on ``ProviderConfigSchema.storage``
to the matching backend.
"""
from __future__ import annotations
import importlib.util
import logging
from dataclasses import dataclass, field as dataclass_field
_log = logging.getLogger(__name__)
# Field kinds understood by the generic renderer.
KIND_TEXT = "text"
KIND_SELECT = "select"
KIND_SECRET = "secret"
KIND_BOOL = "bool"
KIND_NUMBER = "number"
KIND_JSON = "json"
# Storage backends understood by web_server (see its read/write dispatch).
STORAGE_FLAT_JSON = "flat_json"
STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block"
@dataclass(frozen=True)
class ProviderFieldOption:
"""A single choice for a ``select`` field."""
value: str
label: str
description: str = ""
@dataclass(frozen=True)
class ProviderField:
"""One configurable field on a memory provider.
A field is stored in exactly one place, decided by ``kind``:
* non-secret kinds persisted to the provider's config via its storage
backend under ``key``.
* ``secret`` persisted to the env store under ``env_key`` and never read
back out over the API (only an ``is_set`` flag is surfaced).
``aliases`` and ``env_fallbacks`` let a field read legacy values written by
earlier CLI/env setup without re-introducing per-provider code. ``inline``
marks the curated subset shown in the compact panel; the rest surface only
in the full-config modal. ``group`` buckets fields within that modal.
"""
key: str
label: str
kind: str = KIND_TEXT
default: str = ""
description: str = ""
placeholder: str = ""
options: tuple[ProviderFieldOption, ...] = ()
env_key: str | None = None
aliases: tuple[str, ...] = ()
env_fallbacks: tuple[str, ...] = ()
inline: bool = False
group: str = ""
# Longer help text surfaced as an info tooltip next to the field label.
info: str = ""
# Host-block placement: "host" (per-profile) or "root"; flat-json ignores it.
scope: str = "host"
@property
def is_secret(self) -> bool:
return self.kind == KIND_SECRET
def allowed_values(self) -> set[str]:
return {opt.value for opt in self.options}
@dataclass(frozen=True)
class ProviderConfigSchema:
"""A provider plugin's declared config surface."""
name: str
label: str
storage: str = STORAGE_FLAT_JSON
# Optional link to the provider's config docs, shown in the full-config modal.
docs_url: str = ""
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
def inline_fields(self) -> tuple[ProviderField, ...]:
return tuple(f for f in self.fields if f.inline)
_SCHEMA_CACHE: dict[str, ProviderConfigSchema] = {}
def get_provider_config_schema(name: str) -> ProviderConfigSchema | None:
"""Return the ``CONFIG_SCHEMA`` declared by the provider plugin ``name``.
Providers without a ``config_schema.py`` (e.g. ``builtin``) return ``None``
and simply render no config panel. The cache keys on the resolved schema
file, not the name: user-installed plugins are per-profile, so one
profile's lookup must never answer for another's.
"""
from plugins.memory import find_provider_dir
provider_dir = find_provider_dir(name)
path = provider_dir / "config_schema.py" if provider_dir else None
if path is None or not path.is_file():
return None
key = str(path)
if key in _SCHEMA_CACHE:
return _SCHEMA_CACHE[key]
try:
spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{name}", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
schema = getattr(module, "CONFIG_SCHEMA", None)
except Exception:
# Never cache a failed load: it would pin an empty panel until restart.
_log.exception("failed to load config schema for memory provider %r", name)
return None
if schema is not None:
_SCHEMA_CACHE[key] = schema
return schema

View file

@ -0,0 +1,76 @@
"""Hindsight's declared config surface — rendered by the generic desktop panel."""
from plugins.memory.config_schema import (
KIND_SECRET,
KIND_SELECT,
KIND_TEXT,
ProviderConfigSchema,
ProviderField,
ProviderFieldOption,
)
CONFIG_SCHEMA = ProviderConfigSchema(
name="hindsight",
label="Hindsight",
fields=(
ProviderField(
key="mode",
label="Mode",
kind=KIND_SELECT,
default="cloud",
description="How Hermes connects to Hindsight.",
options=(
ProviderFieldOption(
"cloud",
"Cloud",
"Hindsight Cloud API (lightweight, just needs an API key)",
),
ProviderFieldOption(
"local_external",
"Local External",
"Connect to an existing Hindsight instance",
),
),
inline=True,
),
ProviderField(
key="api_key",
label="API key",
kind=KIND_SECRET,
env_key="HINDSIGHT_API_KEY",
description="Used to authenticate with the Hindsight API.",
placeholder="Enter Hindsight API key",
inline=True,
),
ProviderField(
key="api_url",
label="API URL",
kind=KIND_TEXT,
default="https://api.hindsight.vectorize.io",
aliases=("apiUrl",),
env_fallbacks=("HINDSIGHT_API_URL",),
inline=True,
),
ProviderField(
key="bank_id",
label="Bank ID",
kind=KIND_TEXT,
default="hermes",
aliases=("bankId",),
inline=True,
),
ProviderField(
key="recall_budget",
label="Recall budget",
kind=KIND_SELECT,
default="mid",
aliases=("budget",),
options=(
ProviderFieldOption("low", "low"),
ProviderFieldOption("mid", "mid"),
ProviderFieldOption("high", "high"),
),
inline=True,
),
),
)

View file

@ -0,0 +1,324 @@
"""Honcho's declared config surface — rendered by the generic desktop panel."""
from plugins.memory.config_schema import (
KIND_BOOL,
KIND_JSON,
KIND_NUMBER,
KIND_SECRET,
KIND_SELECT,
KIND_TEXT,
STORAGE_HONCHO_HOST_BLOCK,
ProviderConfigSchema,
ProviderField,
ProviderFieldOption,
)
# Reasoning effort levels shared by dialectic-related selects.
_REASONING_LEVELS = (
ProviderFieldOption("minimal", "Minimal"),
ProviderFieldOption("low", "Low"),
ProviderFieldOption("medium", "Medium"),
ProviderFieldOption("high", "High"),
ProviderFieldOption("max", "Max"),
)
CONFIG_SCHEMA = ProviderConfigSchema(
name="honcho",
label="Honcho",
storage=STORAGE_HONCHO_HOST_BLOCK,
docs_url="https://docs.honcho.dev/v3/guides/integrations/hermes",
fields=(
# — Connection —
ProviderField(
key="apiKey",
label="API key",
kind=KIND_SECRET,
env_key="HONCHO_API_KEY",
description="Authenticate with Honcho Cloud. Not needed for a self-hosted base URL.",
placeholder="Enter Honcho API key",
inline=True,
group="Connection",
),
ProviderField(
key="baseUrl",
label="Base URL",
kind=KIND_TEXT,
aliases=("base_url",),
env_fallbacks=("HONCHO_BASE_URL",),
description="Self-hosted Honcho URL. Overrides the environment when set.",
placeholder="https://… (self-hosted)",
inline=True,
group="Connection",
scope="root",
),
ProviderField(
key="environment",
label="Environment",
kind=KIND_SELECT,
default="production",
env_fallbacks=("HONCHO_ENVIRONMENT",),
description="Honcho environment. Ignored when a base URL is set.",
options=(
ProviderFieldOption("production", "Cloud"),
ProviderFieldOption("local", "Local"),
),
inline=True,
group="Connection",
),
ProviderField(
key="workspace",
label="Workspace",
kind=KIND_TEXT,
description="Honcho workspace ID. Defaults to the profile host.",
inline=True,
group="Connection",
),
# — Identity —
ProviderField(
key="peerName",
label="Peer name",
kind=KIND_TEXT,
description="Your stable user peer. Unifies memory across platforms for single-user setups.",
placeholder="e.g. eri",
inline=True,
group="Identity",
),
ProviderField(
key="aiPeer",
label="AI peer",
kind=KIND_TEXT,
description="The AI-side peer name. Defaults to the profile host.",
inline=True,
group="Identity",
),
# — Session —
ProviderField(
key="sessionStrategy",
label="Session strategy",
kind=KIND_SELECT,
default="per-directory",
description="How conversations map to Honcho sessions.",
info=(
"Per session: every conversation gets its own Honcho session. "
"Per directory: conversations from the same working directory share one. "
"Per repo: conversations from the same git repo share one. "
"Global: everything shares a single session."
),
options=(
ProviderFieldOption("per-session", "Per session"),
ProviderFieldOption("per-directory", "Per directory"),
ProviderFieldOption("per-repo", "Per repo"),
ProviderFieldOption("global", "Global"),
),
inline=True,
group="Session",
),
# —————— Full-config-only fields below (inline=False) ——————
# — Connection —
ProviderField(
key="timeout",
label="Request timeout",
kind=KIND_NUMBER,
aliases=("requestTimeout",),
env_fallbacks=("HONCHO_TIMEOUT",),
description="Request timeout in seconds for Honcho HTTP calls. Blank uses the default.",
placeholder="30",
group="Connection",
scope="root",
),
# — Identity —
ProviderField(
key="pinUserPeer",
label="Pin user peer",
kind=KIND_BOOL,
default="false",
aliases=("pinPeerName",),
description="Pin the user peer to the peer name, ignoring gateway runtime identity. Unifies memory for single-user setups.",
group="Identity",
),
ProviderField(
key="runtimePeerPrefix",
label="Runtime peer prefix",
kind=KIND_TEXT,
description="Prefix applied to unknown gateway runtime user IDs.",
placeholder="e.g. telegram_",
group="Identity",
),
ProviderField(
key="userPeerAliases",
label="User peer aliases",
kind=KIND_JSON,
description="Map gateway runtime user IDs to stable Honcho peers.",
placeholder='{"telegram_123": "eri"}',
group="Identity",
),
# — Session —
ProviderField(
key="sessionPeerPrefix",
label="Session peer prefix",
kind=KIND_BOOL,
default="false",
description="Prefix session peer names with the host.",
group="Session",
),
ProviderField(
key="sessions",
label="Session overrides",
kind=KIND_JSON,
description="Explicit session ID overrides keyed by resolver.",
placeholder='{"key": "session-id"}',
group="Session",
scope="root",
),
# — Message writing —
ProviderField(
key="saveMessages",
label="Save messages",
kind=KIND_BOOL,
default="true",
description="Persist conversation messages to Honcho.",
group="Message writing",
),
ProviderField(
key="writeFrequency",
label="Write frequency",
kind=KIND_TEXT,
default="async",
description="When to flush messages: async, turn, session, or every N turns.",
info=(
"async: write in the background as messages arrive. "
"turn: flush after each turn. session: flush when the session ends. "
"A number N flushes every N turns."
),
placeholder="async | turn | session | N",
group="Message writing",
),
# — Dialectic —
ProviderField(
key="dialecticReasoningLevel",
label="Reasoning level",
kind=KIND_SELECT,
default="low",
description="Reasoning effort for dialectic (peer.chat) calls.",
options=_REASONING_LEVELS,
group="Dialectic",
),
ProviderField(
key="dialecticDynamic",
label="Dynamic reasoning",
kind=KIND_BOOL,
default="true",
description="Let the model override the reasoning level per call.",
group="Dialectic",
),
ProviderField(
key="dialecticMaxChars",
label="Max result chars",
kind=KIND_NUMBER,
description="Max chars of dialectic result injected into the system prompt.",
placeholder="1200",
group="Dialectic",
),
ProviderField(
key="dialecticDepth",
label="Depth",
kind=KIND_NUMBER,
description="Dialectic passes per cycle (13).",
placeholder="1",
group="Dialectic",
),
ProviderField(
key="dialecticDepthLevels",
label="Per-pass levels",
kind=KIND_JSON,
description="Reasoning level per pass; array length matches depth.",
placeholder='["low", "medium"]',
group="Dialectic",
),
ProviderField(
key="dialecticMaxInputChars",
label="Max input chars",
kind=KIND_NUMBER,
description="Max chars of query input sent to peer.chat().",
placeholder="10000",
group="Dialectic",
),
# — Reasoning —
ProviderField(
key="reasoningHeuristic",
label="Reasoning heuristic",
kind=KIND_BOOL,
default="true",
description="Scale the reasoning level up on longer queries.",
group="Reasoning",
),
ProviderField(
key="reasoningLevelCap",
label="Reasoning level cap",
kind=KIND_SELECT,
default="high",
description="Ceiling for the heuristic-selected reasoning level.",
options=_REASONING_LEVELS,
group="Reasoning",
),
# — Recall —
ProviderField(
key="recallMode",
label="Recall mode",
kind=KIND_SELECT,
default="hybrid",
description="How memory retrieval works: hybrid, context-only, or tools-only.",
info=(
"Hybrid: auto-injected context plus on-demand memory tools. "
"Context only: injection without tools. "
"Tools only: the model queries memory explicitly, nothing is injected."
),
options=(
ProviderFieldOption("hybrid", "Hybrid"),
ProviderFieldOption("context", "Context only"),
ProviderFieldOption("tools", "Tools only"),
),
group="Recall",
),
ProviderField(
key="contextTokens",
label="Context token cap",
kind=KIND_NUMBER,
description="Cap on auto-injected context tokens. Blank leaves it uncapped.",
placeholder="(uncapped)",
group="Recall",
),
ProviderField(
key="initOnSessionStart",
label="Eager init",
kind=KIND_BOOL,
default="false",
description="Initialize the session eagerly in tools mode instead of on first tool call.",
group="Recall",
),
# — Limits —
ProviderField(
key="messageMaxChars",
label="Message max chars",
kind=KIND_NUMBER,
description="Max chars per message sent to Honcho.",
placeholder="25000",
group="Limits",
),
# — Observation —
ProviderField(
key="observationMode",
label="Observation mode",
kind=KIND_SELECT,
default="directional",
description="Per-peer observation preset. Directional observes all directions; unified shares one view.",
options=(
ProviderFieldOption("directional", "Directional"),
ProviderFieldOption("unified", "Unified"),
),
group="Observation",
),
),
)

View file

@ -491,6 +491,28 @@ class TestWebServerEndpoints:
assert fields["api_key"]["url"] == "https://app.honcho.dev"
assert fields["baseUrl"]["kind"] == "text"
def test_instance_schema_serves_providers_without_declared_schema(self, monkeypatch):
# The default surface serves the plugin instance's get_config_schema().
from hermes_cli import web_server
class _Stub:
def get_config_schema(self):
return [
{"key": "api_key", "description": "Stub API key", "secret": True, "url": "https://stub.example"},
{"key": "baseUrl", "description": "Stub base URL"},
]
monkeypatch.setattr(web_server, "_load_memory_provider", lambda name: _Stub())
resp = self.client.get("/api/memory/providers/mem0/config")
assert resp.status_code == 200
data = resp.json()
fields = self._provider_field_map(data)
assert fields["api_key"]["kind"] == "secret"
assert fields["api_key"]["url"] == "https://stub.example"
assert fields["baseUrl"]["kind"] == "text"
def test_declared_surface_serves_curated_hindsight_schema(self):
resp = self.client.get("/api/memory/providers/hindsight/config?surface=declared")
@ -502,7 +524,7 @@ class TestWebServerEndpoints:
assert fields["api_key"]["kind"] == "secret"
def test_declared_surface_hides_undeclared_providers(self):
resp = self.client.get("/api/memory/providers/honcho/config?surface=declared")
resp = self.client.get("/api/memory/providers/builtin/config?surface=declared")
assert resp.status_code == 200
assert resp.json()["fields"] == []
@ -534,7 +556,7 @@ class TestWebServerEndpoints:
def test_declared_surface_put_rejects_undeclared_provider(self):
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
"/api/memory/providers/builtin/config?surface=declared",
json={"values": {"api_key": "x"}},
)
@ -581,7 +603,9 @@ class TestWebServerEndpoints:
assert config_resp.status_code == 200
assert config_resp.json()["setup"]["external_dependencies"] == byterover_setup["external_dependencies"]
def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch):
def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch, tmp_path):
# Pin HOME so a developer's real ~/.honcho config can't flip the status.
monkeypatch.setenv("HOME", str(tmp_path))
import hermes_cli.web_server as web_server
original_dependency_importable = web_server._dependency_importable
@ -926,6 +950,333 @@ class TestWebServerEndpoints:
fetched = self.client.get("/api/model/moa").json()
assert fetched["presets"]["default"]["fanout"] == "user_turn"
assert fetched["presets"]["default"]["reference_max_tokens"] == 600
# ── Memory provider config (Honcho host-block backend) ──────────────
@pytest.fixture(autouse=True)
def _isolate_honcho_config(self):
# Honcho tests write the suite-wide HERMES_HOME honcho.json; snapshot and
# restore it so provider status/config state never leaks across tests.
from hermes_constants import get_hermes_home
path = get_hermes_home() / "honcho.json"
before = path.read_bytes() if path.exists() else None
yield
if before is None:
path.unlink(missing_ok=True)
else:
path.write_bytes(before)
@staticmethod
def _seed_local_honcho(cfg=None):
from hermes_constants import get_hermes_home
path = get_hermes_home() / "honcho.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(cfg if cfg is not None else {}), encoding="utf-8")
return path
def test_get_honcho_config_returns_safe_defaults(self, monkeypatch, tmp_path):
# HOME isn't isolated by the suite; pin it so ~/.honcho can't leak in.
monkeypatch.setenv("HOME", str(tmp_path))
resp = self.client.get("/api/memory/providers/honcho/config?surface=declared")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "honcho"
assert data["label"] == "Honcho"
assert data["docs_url"] == "https://docs.honcho.dev/v3/guides/integrations/hermes"
fields = self._provider_field_map(data)
assert fields["environment"]["kind"] == "select"
assert fields["environment"]["value"] == "production"
assert {opt["value"] for opt in fields["environment"]["options"]} == {
"production",
"local",
}
assert fields["sessionStrategy"]["value"] == "per-directory"
# Blank workspace/aiPeer surface the resolved host as the placeholder.
assert fields["workspace"]["value"] == ""
assert fields["workspace"]["placeholder"] == "hermes"
assert fields["aiPeer"]["placeholder"] == "hermes"
assert fields["apiKey"]["kind"] == "secret"
assert fields["apiKey"]["is_set"] is False
def test_put_honcho_writes_host_block_root_and_secret(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HONCHO_API_KEY", "guard")
monkeypatch.delenv("HONCHO_API_KEY")
self._seed_local_honcho()
from hermes_constants import get_hermes_home
from hermes_cli.config import load_config, load_env
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={
"values": {
"apiKey": "hch-test-key",
"baseUrl": "https://honcho.example.dev",
"environment": "local",
"workspace": "myws",
"peerName": "eri",
"aiPeer": "hermes",
"sessionStrategy": "per-repo",
}
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert load_config()["memory"]["provider"] == "honcho"
assert load_env()["HONCHO_API_KEY"] == "hch-test-key"
cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
# baseUrl is root-scoped; the rest live in the active host block.
assert cfg["baseUrl"] == "https://honcho.example.dev"
assert cfg["hosts"]["hermes"]["workspace"] == "myws"
assert cfg["hosts"]["hermes"]["peerName"] == "eri"
assert cfg["hosts"]["hermes"]["environment"] == "local"
assert cfg["hosts"]["hermes"]["sessionStrategy"] == "per-repo"
# The key lands where the client reads first; GET keeps it write-only.
assert cfg["hosts"]["hermes"]["apiKey"] == "hch-test-key"
def test_put_honcho_blank_text_clears_key(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
self._seed_local_honcho()
from hermes_constants import get_hermes_home
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"workspace": "myws"}},
)
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"workspace": ""}},
)
cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
assert "workspace" not in cfg.get("hosts", {}).get("hermes", {})
def test_put_honcho_partial_save_preserves_other_keys(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
self._seed_local_honcho()
from hermes_constants import get_hermes_home
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"workspace": "myws"}},
)
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"peerName": "eri"}},
)
host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"]
assert host["workspace"] == "myws"
assert host["peerName"] == "eri"
def test_put_honcho_rejects_unsupported_select_value(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"environment": "bogus"}},
)
assert resp.status_code == 400
def test_get_honcho_config_does_not_return_secret(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HONCHO_API_KEY", "guard")
monkeypatch.delenv("HONCHO_API_KEY")
self._seed_local_honcho()
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"apiKey": "secret-value"}},
)
resp = self.client.get("/api/memory/providers/honcho/config?surface=declared")
assert resp.status_code == 200
data = resp.json()
fields = self._provider_field_map(data)
assert fields["apiKey"]["is_set"] is True
assert fields["apiKey"]["value"] == ""
assert "secret-value" not in json.dumps(data)
def test_put_honcho_bool_stored_natively_and_false_survives(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
self._seed_local_honcho()
from hermes_constants import get_hermes_home
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"saveMessages": "false", "dialecticDynamic": "true"}},
)
host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"]
# Native JSON bools, not the strings "false"/"true" (which read truthy).
assert host["saveMessages"] is False
assert host["dialecticDynamic"] is True
fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json())
assert fields["saveMessages"]["value"] == "false"
assert fields["dialecticDynamic"]["value"] == "true"
def test_put_honcho_number_stored_as_native_number(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
self._seed_local_honcho()
from hermes_constants import get_hermes_home
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"dialecticMaxChars": "1200", "timeout": "2.5"}},
)
cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
assert cfg["hosts"]["hermes"]["dialecticMaxChars"] == 1200
assert isinstance(cfg["hosts"]["hermes"]["dialecticMaxChars"], int)
# timeout is root-scoped and keeps its fractional part.
assert cfg["timeout"] == 2.5
fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json())
assert fields["dialecticMaxChars"]["value"] == "1200"
def test_put_honcho_json_round_trips_object(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
self._seed_local_honcho()
from hermes_constants import get_hermes_home
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"userPeerAliases": '{"telegram_1": "eri"}'}},
)
host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"]
assert host["userPeerAliases"] == {"telegram_1": "eri"}
fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json())
assert json.loads(fields["userPeerAliases"]["value"]) == {"telegram_1": "eri"}
def test_put_honcho_first_save_merges_into_resolved_config(self, monkeypatch, tmp_path):
# With no profile-local file, a save merges into the resolved global config.
monkeypatch.setenv("HOME", str(tmp_path))
from hermes_constants import get_hermes_home
global_path = tmp_path / ".honcho" / "config.json"
global_path.parent.mkdir(parents=True)
global_path.write_text(
json.dumps({"baseUrl": "https://kept.example", "hosts": {"hermes": {"workspace": "kept"}}}),
encoding="utf-8",
)
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"peerName": "eri"}},
)
assert resp.status_code == 200
assert not (get_hermes_home() / "honcho.json").exists()
cfg = json.loads(global_path.read_text(encoding="utf-8"))
assert cfg["baseUrl"] == "https://kept.example"
assert cfg["hosts"]["hermes"] == {"workspace": "kept", "peerName": "eri"}
def test_put_honcho_updates_legacy_dot_form_host_block(self, monkeypatch, tmp_path):
# The legacy dot-form block reads resolve is updated in place, not shadowed.
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HONCHO_HOST", "hermes_work")
path = self._seed_local_honcho({"hosts": {"hermes.work": {"workspace": "w", "peerName": "eri"}}})
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"sessionStrategy": "per-repo"}},
)
assert resp.status_code == 200
hosts = json.loads(path.read_text(encoding="utf-8"))["hosts"]
assert set(hosts) == {"hermes.work"}
assert hosts["hermes.work"] == {"workspace": "w", "peerName": "eri", "sessionStrategy": "per-repo"}
def test_put_honcho_api_key_never_overwrites_oauth_token(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HONCHO_API_KEY", "guard")
monkeypatch.delenv("HONCHO_API_KEY")
from hermes_cli.config import load_env
path = self._seed_local_honcho({"hosts": {"hermes": {"apiKey": "hch-at-oauth-token"}}})
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"apiKey": "manual-key"}},
)
assert resp.status_code == 200
cfg = json.loads(path.read_text(encoding="utf-8"))
# The OAuth grant owns the JSON slot; the manual key lands in the env store.
assert cfg["hosts"]["hermes"]["apiKey"] == "hch-at-oauth-token"
assert load_env()["HONCHO_API_KEY"] == "manual-key"
def test_put_honcho_tolerates_null_hosts(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
path = self._seed_local_honcho({"hosts": None})
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"workspace": "myws"}},
)
assert resp.status_code == 200
assert json.loads(path.read_text(encoding="utf-8"))["hosts"]["hermes"]["workspace"] == "myws"
def test_memory_provider_config_honors_profile_param(self, monkeypatch, tmp_path):
# A ?profile= save must land in that profile's config, not the serving
# process's — same contract as the skills/toolsets endpoints.
monkeypatch.setenv("HOME", str(tmp_path))
# The suite pins HERMES_HONCHO_HOST=hermes; this test exercises
# profile-driven host resolution, so drop the override explicitly.
monkeypatch.delenv("HERMES_HONCHO_HOST", raising=False)
from hermes_constants import get_hermes_home
from hermes_cli.profiles import get_profile_dir
self._seed_local_honcho()
worker_home = get_profile_dir("worker")
worker_home.mkdir(parents=True, exist_ok=True)
worker_cfg = worker_home / "honcho.json"
worker_cfg.write_text(json.dumps({"hosts": {"hermes_worker": {"workspace": "kept"}}}), encoding="utf-8")
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared&profile=worker",
json={"values": {"peerName": "eri"}},
)
assert resp.status_code == 200
worker_hosts = json.loads(worker_cfg.read_text(encoding="utf-8"))["hosts"]
host_block = next(iter(worker_hosts.values()))
assert host_block["peerName"] == "eri"
# The serving process's own config is untouched.
own = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
assert "peerName" not in json.dumps(own)
fields = self._provider_field_map(
self.client.get("/api/memory/providers/honcho/config?surface=declared&profile=worker").json()
)
assert fields["peerName"]["value"] == "eri"
def test_put_honcho_rejects_malformed_number_and_json(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
assert self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"dialecticMaxChars": "lots"}},
).status_code == 400
assert self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"userPeerAliases": "{not json"}},
).status_code == 400
# ── GET /api/media (remote image display) ───────────────────────────
@ -3843,10 +4194,12 @@ class TestBuildSchemaFromConfig:
assert entry["type"] == "select"
assert entry["category"] == "memory"
options = entry["options"]
# Built-in sentinel first, plus at least one discovered provider.
# Built-in-only sentinel first, plus at least one discovered provider.
# The literal "builtin" alias must NOT be offered — built-in memory is
# not a provider plugin (#49513).
assert options[0] == ""
assert "builtin" in options
assert len(options) >= 3
assert "builtin" not in options
assert len(options) >= 2
def test_memory_provider_options_cover_discovered_providers(self):
"""Every provider the /api/memory endpoint can activate is selectable."""

View file

@ -0,0 +1,67 @@
"""Tests for config-schema loading from memory provider plugin dirs."""
import plugins.memory.config_schema as config_schema
from plugins.memory.config_schema import get_provider_config_schema
def test_unknown_provider_is_none():
assert get_provider_config_schema("builtin") is None
def test_plugin_without_schema_is_none():
# mem0 is a real plugin dir that declares no config_schema.py.
assert get_provider_config_schema("mem0") is None
def test_schemas_are_cached_per_provider():
assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho")
def test_cache_keys_on_schema_path_not_name(monkeypatch, tmp_path):
# User-installed plugins are per-profile; two profiles' plugins sharing a
# name must not answer for each other.
import plugins.memory as memory
schemas = {}
for label in ("A", "B"):
plugin_dir = tmp_path / label / "custom"
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.py").write_text(
"from plugins.memory.config_schema import ProviderConfigSchema\n"
f'CONFIG_SCHEMA = ProviderConfigSchema(name="custom", label="{label}")\n',
encoding="utf-8",
)
schemas[label] = plugin_dir
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["A"])
assert get_provider_config_schema("custom").label == "A"
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["B"])
assert get_provider_config_schema("custom").label == "B"
def test_broken_schema_is_not_cached(monkeypatch, tmp_path):
# A load failure must retry on the next request, not pin an empty panel.
broken_dir = tmp_path / "broken"
broken_dir.mkdir()
schema_file = broken_dir / "config_schema.py"
schema_file.write_text("this is not python(", encoding="utf-8")
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
import plugins.memory as memory
monkeypatch.setattr(memory, "find_provider_dir", lambda name: broken_dir)
assert get_provider_config_schema("broken") is None
assert not config_schema._SCHEMA_CACHE
schema_file.write_text(
"from plugins.memory.config_schema import ProviderConfigSchema\n"
'CONFIG_SCHEMA = ProviderConfigSchema(name="broken", label="Broken")\n',
encoding="utf-8",
)
recovered = get_provider_config_schema("broken")
assert recovered is not None
assert recovered.label == "Broken"

View file

@ -1,14 +1,14 @@
"""Tests for the declarative memory-provider registry."""
"""Tests for Hindsight's declared config surface."""
from hermes_cli.memory_providers import (
from plugins.memory.config_schema import (
KIND_SECRET,
KIND_SELECT,
get_memory_provider,
get_provider_config_schema,
)
def test_hindsight_is_declared():
provider = get_memory_provider("hindsight")
provider = get_provider_config_schema("hindsight")
assert provider is not None
assert provider.label == "Hindsight"
@ -21,8 +21,17 @@ def test_hindsight_is_declared():
}
def test_hindsight_mode_gating_is_expressed_as_select_options():
provider = get_memory_provider("hindsight")
def test_fields_are_all_inline():
provider = get_provider_config_schema("hindsight")
assert provider is not None
# Hindsight is simple enough to render fully in the compact panel, so it
# never grows a Full config… modal.
assert all(field.inline for field in provider.fields)
def test_mode_gating_is_expressed_as_select_options():
provider = get_provider_config_schema("hindsight")
assert provider is not None
mode = next(field for field in provider.fields if field.key == "mode")
@ -33,14 +42,10 @@ def test_hindsight_mode_gating_is_expressed_as_select_options():
def test_api_key_is_a_secret_bound_to_env():
provider = get_memory_provider("hindsight")
provider = get_provider_config_schema("hindsight")
assert provider is not None
api_key = next(field for field in provider.fields if field.key == "api_key")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HINDSIGHT_API_KEY"
def test_unknown_provider_is_none():
assert get_memory_provider("builtin") is None

View file

@ -0,0 +1,90 @@
"""Tests for Honcho's declared config surface."""
from plugins.memory.config_schema import (
KIND_BOOL,
KIND_JSON,
KIND_NUMBER,
KIND_SECRET,
KIND_SELECT,
STORAGE_HONCHO_HOST_BLOCK,
get_provider_config_schema,
)
# The curated set shown in the compact panel; everything else lives in the modal.
INLINE_KEYS = {
"apiKey",
"baseUrl",
"environment",
"workspace",
"peerName",
"aiPeer",
"sessionStrategy",
}
def test_honcho_is_declared():
provider = get_provider_config_schema("honcho")
assert provider is not None
assert provider.label == "Honcho"
assert provider.storage == STORAGE_HONCHO_HOST_BLOCK
# Field keys are unique, and the curated inline set is present.
keys = [field.key for field in provider.fields]
assert len(keys) == len(set(keys))
assert INLINE_KEYS <= set(keys)
def test_inline_fields_are_the_curated_subset():
provider = get_provider_config_schema("honcho")
assert provider is not None
assert {field.key for field in provider.inline_fields()} == INLINE_KEYS
# The modal-only fields are a non-empty remainder.
non_inline = {f.key for f in provider.fields} - INLINE_KEYS
assert {"writeFrequency", "recallMode", "userPeerAliases"} <= non_inline
def test_declares_the_new_field_kinds():
provider = get_provider_config_schema("honcho")
assert provider is not None
by_key = {f.key: f for f in provider.fields}
assert by_key["saveMessages"].kind == KIND_BOOL
assert by_key["dialecticMaxChars"].kind == KIND_NUMBER
assert by_key["userPeerAliases"].kind == KIND_JSON
assert by_key["recallMode"].allowed_values() == {"hybrid", "context", "tools"}
assert by_key["observationMode"].allowed_values() == {"directional", "unified"}
def test_selects_constrain_their_values():
provider = get_provider_config_schema("honcho")
assert provider is not None
environment = next(f for f in provider.fields if f.key == "environment")
assert environment.kind == KIND_SELECT
# Honcho SDK only accepts local/production; "demo" is not a valid environment.
assert environment.allowed_values() == {"production", "local"}
strategy = next(f for f in provider.fields if f.key == "sessionStrategy")
assert strategy.allowed_values() == {"per-directory", "per-repo", "per-session", "global"}
def test_api_key_is_a_secret_bound_to_env():
provider = get_provider_config_schema("honcho")
assert provider is not None
api_key = next(f for f in provider.fields if f.key == "apiKey")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HONCHO_API_KEY"
def test_root_scoped_fields_are_exactly_the_global_ones():
provider = get_provider_config_schema("honcho")
assert provider is not None
scopes = {f.key: f.scope for f in provider.fields}
root_keys = {k for k, scope in scopes.items() if scope == "root"}
# baseUrl, timeout and sessions live at the config root in Honcho's schema;
# everything else is per-profile host-scoped.
assert root_keys == {"baseUrl", "timeout", "sessions"}