diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index fa9e00eb3497..571a3812b89b 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -18,14 +18,21 @@ import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' -import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' +import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS } from './constants' import { FallbackModelsField } from './fallback-models-field' import { fieldCopyForSchemaKey } from './field-copy' -import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' +import { + enumOptionsFor, + getNested, + isExternalMemoryProvider, + prettyName, + sectionFieldEntries, + setNested +} from './helpers' import { MemoryConnect } from './memory/connect' +import { ProviderConfigPanel } from './memory/provider-config-panel' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' -import { ProviderConfigPanel } from './provider-config-panel' // On the Voice page, only surface the sub-fields of the *selected* TTS/STT // provider — otherwise every provider's options render at once (the "totally @@ -134,7 +141,9 @@ function ConfigField({ ? (optionLabels?.[option] ?? prettyName(option)) : schemaKey === 'display.personality' ? c.none - : c.noneParen} + : schemaKey === 'memory.provider' + ? c.builtinOnly + : c.noneParen} ))} @@ -336,14 +345,12 @@ export function ConfigSettings({ } const sectionFields = useMemo(() => { - if (!schema) { + if (!schema || !config) { return new Map() } - 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({
) : 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) ? ( - + {key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? ( + ) : null}
))} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 3bce38af97a6..2eecd030b0e0 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -247,7 +247,10 @@ export const ENUM_OPTIONS: Record = { '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). diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index d29f2a739436..7b618822e836 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -3,13 +3,43 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy' -import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' +import { + enumOptionsFor, + getNested, + isExternalMemoryProvider, + providerGroup, + sectionFieldEntries, + setNested, + stripToolsetLabel, + toolsetDisplayLabel +} from './helpers' describe('settings helpers', () => { - it('lists Hindsight as a built-in desktop memory provider option', () => { + it('lists the desktop memory provider options in their declared order', () => { const options = enumOptionsFor('memory.provider', '', {}) - expect(options).toContain('hindsight') + // Built-in memory is not a provider plugin; the empty sentinel is the + // only built-in-shaped entry (#49513). + expect(options).toEqual(['', 'honcho', 'hindsight']) + }) + + it('keeps a legacy literal builtin value visible as the current selection', () => { + const options = enumOptionsFor('memory.provider', 'builtin', {}) + + expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin']) + }) + + describe('isExternalMemoryProvider', () => { + it('treats only real plugin names as external providers', () => { + expect(isExternalMemoryProvider('honcho')).toBe(true) + expect(isExternalMemoryProvider('hindsight')).toBe(true) + }) + + it('treats built-in aliases and empty values as not external', () => { + for (const value of ['', 'builtin', 'built-in', 'Builtin', 'none', ' ', undefined, null, 7]) { + expect(isExternalMemoryProvider(value)).toBe(false) + } + }) }) describe('defineFieldCopy', () => { @@ -176,4 +206,39 @@ describe('settings helpers', () => { expect(opts).toContain('xai') }) }) + + describe('sectionFieldEntries', () => { + it('renders memory.provider from config even when the backend schema omits it', () => { + const schema = { 'memory.memory_enabled': { type: 'boolean' as const } } + const config: HermesConfigRecord = { memory: { memory_enabled: true, provider: '' } } + + const memoryKeys = (sectionFieldEntries(schema, config).get('memory') ?? []).map(([key]) => key) + + expect(memoryKeys).toContain('memory.provider') + }) + + it('infers the field type from the config value when the schema omits the key', () => { + const config: HermesConfigRecord = { memory: { provider: '', memory_enabled: true, memory_char_limit: 2200 } } + + const fields = new Map(sectionFieldEntries({}, config).get('memory') ?? []) + + expect(fields.get('memory.provider')?.type).toBe('string') + expect(fields.get('memory.memory_enabled')?.type).toBe('boolean') + expect(fields.get('memory.memory_char_limit')?.type).toBe('number') + }) + + it('prefers the backend schema entry over inference when both exist', () => { + const schema = { 'memory.provider': { type: 'select' as const, options: ['honcho'] } } + const config: HermesConfigRecord = { memory: { provider: 'honcho' } } + + const field = new Map(sectionFieldEntries(schema, config).get('memory') ?? []).get('memory.provider') + + expect(field?.type).toBe('select') + expect(field?.options).toEqual(['honcho']) + }) + + it('hides declared keys absent from both schema and config', () => { + expect(sectionFieldEntries({}, {}).get('memory') ?? []).toHaveLength(0) + }) + }) }) diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index ff5818129053..2c3b8e9b01d1 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -1,7 +1,7 @@ import { asText } from '@/lib/text' -import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes' +import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes' -import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants' +import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants' // Canonical implementations live in @/lib/text; re-exported here so the many // settings/capabilities call sites keep their import path. @@ -97,6 +97,40 @@ export function getNested(obj: HermesConfigRecord, path: string): unknown { return cur } +export function inferFieldSchema(value: unknown): ConfigFieldSchema { + if (typeof value === 'boolean') { + return { type: 'boolean' } + } + + if (typeof value === 'number') { + return { type: 'number' } + } + + if (Array.isArray(value)) { + return { type: 'list' } + } + + return { type: 'string' } +} + +// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal. +export function sectionFieldEntries( + schema: Record, + config: HermesConfigRecord +): Map { + 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' +} diff --git a/apps/desktop/src/app/settings/memory/field-control.tsx b/apps/desktop/src/app/settings/memory/field-control.tsx new file mode 100644 index 000000000000..62871be910ff --- /dev/null +++ b/apps/desktop/src/app/settings/memory/field-control.tsx @@ -0,0 +1,128 @@ +import { Input } from '@/components/ui/input' +import { Tip } from '@/components/ui/tooltip' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Check, Info } from '@/lib/icons' +import type { MemoryProviderField } from '@/types/hermes' + +import { CONTROL_TEXT } from '../constants' + +// Fade the placeholder well below set values so example text never reads as data. +const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45` + +// Field label with an optional info tooltip, shared by the panel and modal rows. +export function FieldTitle({ field }: { field: MemoryProviderField }) { + if (!field.info) { + return <>{field.label} + } + + return ( + + {field.label} + + + + + ) +} + +// 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 set(checked ? 'true' : 'false')} /> + } + + if (field.kind === 'number') { + return ( + onChange(event.target.value)} + placeholder={field.placeholder} + type="number" + value={value} + /> + ) + } + + if (field.kind === 'json') { + return ( +