mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-25 19:33:46 +08:00
Compare commits
1 Commits
salvage/em
...
bb/desktop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e48d18c8e2 |
@@ -276,6 +276,27 @@ class MemoryProvider(ABC):
|
||||
should all have ``env_var`` set and this method stays no-op).
|
||||
"""
|
||||
|
||||
def read_current_config(self) -> Dict[str, Any]:
|
||||
"""Return persisted config values for schema-driven setup UIs.
|
||||
|
||||
Powers the desktop memory-provider config panel (and any other
|
||||
editor built off ``get_config_schema()``): on open, declared fields
|
||||
are pre-filled with the values the provider previously saved via
|
||||
``save_config()``. The mirror image of ``save_config()`` — it reads
|
||||
back what that wrote.
|
||||
|
||||
Return the provider's stored config as a flat dict keyed by the same
|
||||
``key`` names used in ``get_config_schema()``. Secret values may be
|
||||
included or omitted; callers MUST treat fields marked ``secret`` as
|
||||
write-only and never echo them back regardless. Default returns
|
||||
``{}`` (the UI falls back to schema defaults). Providers that persist
|
||||
non-secret config should override this — usually a one-liner
|
||||
delegating to the same loader ``initialize()`` uses.
|
||||
|
||||
Must not raise — return ``{}`` on any error.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def on_memory_write(
|
||||
self,
|
||||
action: str,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getHermesConfigDefaults,
|
||||
getHermesConfigRecord,
|
||||
getHermesConfigSchema,
|
||||
getMemoryStatus,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -198,6 +199,8 @@ export function ConfigSettings({
|
||||
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
||||
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
|
||||
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
|
||||
const [memoryProviderOptions, setMemoryProviderOptions] = useState<string[] | null>(null)
|
||||
const [memoryProviderLabels, setMemoryProviderLabels] = useState<Record<string, string>>({})
|
||||
const saveVersionRef = useRef(0)
|
||||
const [saveVersion, setSaveVersion] = useState(0)
|
||||
|
||||
@@ -240,6 +243,37 @@ export function ConfigSettings({
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
// Memory provider dropdown is driven by backend discovery (bundled +
|
||||
// user-installed + pip plugins), not a hardcoded enum — every discovered
|
||||
// provider shows up automatically. '' is the built-in (MEMORY.md/USER.md).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getMemoryStatus()
|
||||
.then(status => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const names = status.providers.map(p => p.name)
|
||||
setMemoryProviderOptions(['', ...names])
|
||||
setMemoryProviderLabels({
|
||||
'': 'Built-in (MEMORY.md / USER.md)',
|
||||
...Object.fromEntries(
|
||||
status.providers.map(p => [p.name, p.description ? `${p.name} — ${p.description}` : p.name])
|
||||
)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setMemoryProviderOptions(null)
|
||||
setMemoryProviderLabels({})
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || saveVersion === 0) {
|
||||
return
|
||||
@@ -361,10 +395,18 @@ export function ConfigSettings({
|
||||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
: key === 'memory.provider'
|
||||
? enumOptionsFor(key, getNested(config, key), config, memoryProviderOptions ?? [''])
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
}
|
||||
onChange={value => updateConfig(setNested(config, key, value))}
|
||||
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
|
||||
optionLabels={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? elevenLabsVoiceLabels
|
||||
: key === 'memory.provider'
|
||||
? memoryProviderLabels
|
||||
: undefined
|
||||
}
|
||||
schema={field}
|
||||
schemaKey={key}
|
||||
value={getNested(config, key)}
|
||||
|
||||
@@ -239,7 +239,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'],
|
||||
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
|
||||
// memory.provider is intentionally absent: the dropdown is populated at
|
||||
// runtime from backend discovery (discover_memory_providers) in
|
||||
// config-settings.tsx, so bundled + user-installed + pip providers all show
|
||||
// up without hand-editing this list.
|
||||
// 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).
|
||||
|
||||
@@ -6,10 +6,18 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from
|
||||
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('lists Hindsight as a built-in desktop memory provider option', () => {
|
||||
const options = enumOptionsFor('memory.provider', '', {})
|
||||
it('has no hardcoded memory.provider enum (driven by backend discovery)', () => {
|
||||
// The dropdown is populated at runtime from discover_memory_providers();
|
||||
// there must be no static enum here or new providers won't appear.
|
||||
expect(enumOptionsFor('memory.provider', '', {})).toBeUndefined()
|
||||
})
|
||||
|
||||
expect(options).toContain('hindsight')
|
||||
it('keeps the current memory provider selectable while discovery loads', () => {
|
||||
// config-settings passes [''] as the fallback before discovery resolves;
|
||||
// the active provider is appended so it never vanishes from the dropdown.
|
||||
const options = enumOptionsFor('memory.provider', 'hindsight', {}, [''])
|
||||
|
||||
expect(options).toEqual(['', 'hindsight'])
|
||||
})
|
||||
|
||||
describe('defineFieldCopy', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
const getMemoryProviderConfig = vi.fn()
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
@@ -16,62 +16,65 @@ vi.mock('@/store/notifications', () => ({
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
|
||||
const fields: MemoryProviderConfig['fields'] = [
|
||||
{
|
||||
function field(partial: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key'>): MemoryProviderField {
|
||||
return {
|
||||
default: '',
|
||||
description: '',
|
||||
is_set: false,
|
||||
kind: 'text',
|
||||
label: partial.key,
|
||||
options: [],
|
||||
placeholder: '',
|
||||
required: false,
|
||||
url: '',
|
||||
value: '',
|
||||
value_type: 'str',
|
||||
when: [],
|
||||
...partial
|
||||
}
|
||||
}
|
||||
|
||||
function hindsightSchema(overrides: Partial<MemoryProviderField>[] = []): MemoryProviderConfig {
|
||||
const fields: MemoryProviderField[] = [
|
||||
field({
|
||||
key: 'mode',
|
||||
label: 'Mode',
|
||||
kind: 'select',
|
||||
value: 'cloud',
|
||||
description: 'How Hermes connects to Hindsight.',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
description: 'How Hermes connects to Hindsight.',
|
||||
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' }
|
||||
]
|
||||
},
|
||||
{
|
||||
}),
|
||||
field({
|
||||
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: [] },
|
||||
{
|
||||
placeholder: 'Enter Hindsight API key'
|
||||
}),
|
||||
field({ key: 'api_url', label: 'API URL', value: 'https://api.hindsight.vectorize.io', is_set: true }),
|
||||
field({ key: 'bank_id', label: 'Bank ID', value: 'hermes', is_set: true }),
|
||||
field({
|
||||
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] }))
|
||||
fields: fields.map((f, index) => ({ ...f, ...overrides[index] }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,4 +142,55 @@ describe('ProviderConfigPanel', () => {
|
||||
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
|
||||
expect(container.querySelector('section')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows and hides fields based on their when-clause as the mode changes', async () => {
|
||||
getMemoryProviderConfig.mockResolvedValue({
|
||||
name: 'hindsight',
|
||||
label: 'Hindsight',
|
||||
fields: [
|
||||
field({
|
||||
key: 'mode',
|
||||
label: 'Mode',
|
||||
kind: 'select',
|
||||
value: 'cloud',
|
||||
options: [
|
||||
{ value: 'cloud', label: 'Cloud', description: '' },
|
||||
{ value: 'local_embedded', label: 'Local Embedded', description: '' }
|
||||
]
|
||||
}),
|
||||
field({ key: 'api_url', label: 'API URL', value: 'https://api', when: [{ key: 'mode', value: 'cloud' }] }),
|
||||
field({ key: 'llm_model', label: 'LLM model', value: 'gpt-4o-mini', when: [{ key: 'mode', value: 'local_embedded' }] })
|
||||
]
|
||||
})
|
||||
|
||||
await renderPanel()
|
||||
|
||||
// Cloud is selected: the cloud-gated field shows, the embedded one doesn't.
|
||||
expect(await screen.findByLabelText('API URL')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('LLM model')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Local Embedded' }))
|
||||
|
||||
// Switching to local_embedded flips which gated field is visible.
|
||||
expect(await screen.findByLabelText('LLM model')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('API URL')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a boolean field as a toggle and saves it as a string', async () => {
|
||||
getMemoryProviderConfig.mockResolvedValue({
|
||||
name: 'hindsight',
|
||||
label: 'Hindsight',
|
||||
fields: [field({ key: 'auto_recall', label: 'Auto recall', kind: 'boolean', value: 'true', value_type: 'bool', is_set: true })]
|
||||
})
|
||||
|
||||
await renderPanel()
|
||||
|
||||
const toggle = await screen.findByRole('switch')
|
||||
expect(toggle).toBeTruthy()
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', { auto_recall: 'false' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { Switch } from '@/components/ui/switch'
|
||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
import { Check, Loader2, Save } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -12,12 +13,28 @@ 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). */
|
||||
/** A field is active only when every clause in its `when` matches the current
|
||||
* values (e.g. Hindsight's `api_url` shows only `when` `mode === 'cloud'`). */
|
||||
function whenMatches(field: MemoryProviderField, values: Record<string, string>): boolean {
|
||||
return field.when.every(clause => String(values[clause.key] ?? '') === clause.value)
|
||||
}
|
||||
|
||||
/** Seed editable values from the schema. Secrets always start blank (their
|
||||
* value is never returned). Conditional fields are seeded against the
|
||||
* unconditional ones first so duplicate keys (same key, different `when`)
|
||||
* resolve to the variant that matches the current selection. */
|
||||
function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])
|
||||
)
|
||||
const values: Record<string, string> = {}
|
||||
const seed = (field: MemoryProviderField) => {
|
||||
values[field.key] = field.kind === 'secret' ? '' : field.value
|
||||
}
|
||||
|
||||
config.fields.filter(f => f.when.length === 0).forEach(seed)
|
||||
config.fields.filter(f => f.when.length > 0 && whenMatches(f, values)).forEach(seed)
|
||||
// Backfill any key not yet seeded (hidden variants) so saves never send undefined.
|
||||
config.fields.filter(f => !(f.key in values)).forEach(seed)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
@@ -53,6 +70,15 @@ function FieldControl({
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'boolean') {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={value === 'true'} onCheckedChange={checked => onChange(checked ? 'true' : 'false')} />
|
||||
<span className="text-xs text-muted-foreground">{value === 'true' ? 'On' : 'Off'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'secret') {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -133,6 +159,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
||||
}
|
||||
|
||||
const secretFields = config.fields.filter(field => field.kind === 'secret')
|
||||
const visibleFields = config.fields.filter(field => whenMatches(field, values))
|
||||
|
||||
return (
|
||||
<section className="py-3">
|
||||
@@ -155,9 +182,22 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
||||
|
||||
{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>
|
||||
{visibleFields.map((field, index) => (
|
||||
<label className="grid gap-1.5" key={`${field.key}-${index}`}>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive">*</span>}
|
||||
{field.url && (
|
||||
<a
|
||||
className="font-normal text-primary underline-offset-2 hover:underline"
|
||||
href={field.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Get key
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
MemoryProviderConfig,
|
||||
MemoryStatus,
|
||||
MessagingPlatformsResponse,
|
||||
MessagingPlatformTestResponse,
|
||||
MessagingPlatformUpdate,
|
||||
@@ -341,6 +342,13 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
|
||||
})
|
||||
}
|
||||
|
||||
export function getMemoryStatus(): Promise<MemoryStatus> {
|
||||
return window.hermesDesktop.api<MemoryStatus>({
|
||||
...profileScoped(),
|
||||
path: '/api/memory'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
|
||||
return window.hermesDesktop.api<MemoryProviderConfig>({
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface EnvVarInfo {
|
||||
url: null | string
|
||||
}
|
||||
|
||||
export type MemoryProviderFieldKind = 'secret' | 'select' | 'text'
|
||||
export type MemoryProviderFieldKind = 'boolean' | 'secret' | 'select' | 'text'
|
||||
|
||||
export interface MemoryProviderFieldOption {
|
||||
description: string
|
||||
@@ -121,7 +121,15 @@ export interface MemoryProviderFieldOption {
|
||||
value: string
|
||||
}
|
||||
|
||||
/** A conditional-visibility clause: the field is shown only when the field
|
||||
* named `key` currently equals `value`. */
|
||||
export interface MemoryProviderFieldWhen {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MemoryProviderField {
|
||||
default: string
|
||||
description: string
|
||||
is_set: boolean
|
||||
key: string
|
||||
@@ -129,7 +137,11 @@ export interface MemoryProviderField {
|
||||
label: string
|
||||
options: MemoryProviderFieldOption[]
|
||||
placeholder: string
|
||||
required: boolean
|
||||
url: string
|
||||
value: string
|
||||
value_type: string
|
||||
when: MemoryProviderFieldWhen[]
|
||||
}
|
||||
|
||||
export interface MemoryProviderConfig {
|
||||
@@ -138,6 +150,18 @@ export interface MemoryProviderConfig {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MemoryProviderInfo {
|
||||
configured: boolean
|
||||
description: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MemoryStatus {
|
||||
active: string
|
||||
builtin_files: Record<string, number>
|
||||
providers: MemoryProviderInfo[]
|
||||
}
|
||||
|
||||
export interface MessagingEnvVarInfo {
|
||||
advanced: boolean
|
||||
description: string
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
"""Declarative configuration schema for desktop memory providers.
|
||||
"""Schema-driven configuration surface 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.
|
||||
Memory providers already declare their configurable fields via
|
||||
``MemoryProvider.get_config_schema()`` (the same declaration ``hermes memory
|
||||
setup`` walks). This module is the *adapter* that normalizes those raw
|
||||
declarations into a stable, JSON-serializable shape the desktop config panel
|
||||
renders generically — no per-provider UI, no hand-maintained registry.
|
||||
|
||||
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.
|
||||
Combined with ``discover_memory_providers()`` driving the dropdown, adding or
|
||||
porting a provider is pure declaration: implement ``get_config_schema()`` (and
|
||||
optionally ``read_current_config()`` / ``save_config()``) and the provider
|
||||
shows up, configured, in the desktop UI with zero bespoke code.
|
||||
|
||||
This module is intentionally pure transformation: it imports nothing from the
|
||||
config/env layer and does no I/O. ``web_server`` owns loading the live
|
||||
provider, reading current values, writing via the provider's ``save_config()``,
|
||||
and persisting secrets to the env store. Keeping the mapping here (and the I/O
|
||||
there) is what lets the same normalized schema drive both the HTTP payload and
|
||||
its validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Field kinds understood by the generic renderer.
|
||||
KIND_TEXT = "text"
|
||||
KIND_SELECT = "select"
|
||||
KIND_SECRET = "secret"
|
||||
KIND_BOOLEAN = "boolean"
|
||||
|
||||
# Native value types, derived from a field's declared default. Submitted form
|
||||
# values arrive as strings; ``coerce_value`` casts them back to these so a
|
||||
# provider's config file keeps booleans/numbers rather than stringified ones.
|
||||
VALUE_STR = "str"
|
||||
VALUE_BOOL = "bool"
|
||||
VALUE_INT = "int"
|
||||
VALUE_FLOAT = "float"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -33,29 +50,33 @@ class ProviderFieldOption:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderField:
|
||||
"""One configurable field on a memory provider.
|
||||
"""One configurable field, normalized from a provider's schema entry.
|
||||
|
||||
A field is stored in exactly one place, decided by ``kind``:
|
||||
Storage is decided by ``kind``:
|
||||
|
||||
* ``text`` / ``select`` — persisted to the provider's JSON config file
|
||||
(``<hermes_home>/<provider>/config.json``) under ``key``.
|
||||
* ``text`` / ``select`` / ``boolean`` — persisted by the provider's own
|
||||
``save_config()`` to its native location, keyed by ``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).
|
||||
back 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.
|
||||
``when`` carries a provider's conditional-visibility clause (e.g. Hindsight
|
||||
only shows ``api_url`` ``when`` ``mode == cloud``); the renderer hides
|
||||
fields whose clause doesn't match the current values, and the same clause
|
||||
gates server-side validation so hidden fields aren't required.
|
||||
"""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
kind: str = KIND_TEXT
|
||||
value_type: str = VALUE_STR
|
||||
default: str = ""
|
||||
description: str = ""
|
||||
placeholder: str = ""
|
||||
options: tuple[ProviderFieldOption, ...] = ()
|
||||
env_key: str | None = None
|
||||
aliases: tuple[str, ...] = ()
|
||||
env_fallbacks: tuple[str, ...] = ()
|
||||
required: bool = False
|
||||
url: str = ""
|
||||
options: Tuple[ProviderFieldOption, ...] = ()
|
||||
env_key: Optional[str] = None
|
||||
when: Tuple[Tuple[str, str], ...] = ()
|
||||
|
||||
@property
|
||||
def is_secret(self) -> bool:
|
||||
@@ -64,86 +85,166 @@ class ProviderField:
|
||||
def allowed_values(self) -> set[str]:
|
||||
return {opt.value for opt in self.options}
|
||||
|
||||
def when_matches(self, values: Dict[str, Any]) -> bool:
|
||||
"""Whether this field is active given the current field values."""
|
||||
|
||||
return all(str(values.get(k, "")) == v for k, v in self.when)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryProvider:
|
||||
"""A declared memory provider and its configurable fields."""
|
||||
"""A declared memory provider and its normalized configurable fields."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
|
||||
fields: Tuple[ProviderField, ...] = ()
|
||||
|
||||
def field(self, key: str) -> Optional[ProviderField]:
|
||||
for f in self.fields:
|
||||
if f.key == key:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
def _label_from_key(raw: str) -> str:
|
||||
"""Humanize a snake/kebab key into a field/provider label."""
|
||||
|
||||
cleaned = raw.replace("_", " ").replace("-", " ").strip()
|
||||
if not cleaned:
|
||||
return raw
|
||||
# Title-case but leave acronym-ish all-caps tokens intact.
|
||||
return " ".join(w if w.isupper() else w.capitalize() for w in cleaned.split())
|
||||
|
||||
|
||||
# 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 _value_type_of(default: Any) -> str:
|
||||
# bool first — bool is a subclass of int.
|
||||
if isinstance(default, bool):
|
||||
return VALUE_BOOL
|
||||
if isinstance(default, int):
|
||||
return VALUE_INT
|
||||
if isinstance(default, float):
|
||||
return VALUE_FLOAT
|
||||
return VALUE_STR
|
||||
|
||||
|
||||
def get_memory_provider(name: str) -> MemoryProvider | None:
|
||||
"""Return the declared provider for ``name``, or ``None`` if undeclared."""
|
||||
def _default_to_str(default: Any) -> str:
|
||||
if default is None:
|
||||
return ""
|
||||
if isinstance(default, bool):
|
||||
return "true" if default else "false"
|
||||
return str(default)
|
||||
|
||||
return MEMORY_PROVIDERS.get(name)
|
||||
|
||||
def _field_from_schema(raw: Dict[str, Any]) -> Optional[ProviderField]:
|
||||
"""Normalize one ``get_config_schema()`` entry into a ``ProviderField``."""
|
||||
|
||||
key = str(raw.get("key") or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
|
||||
default = raw.get("default")
|
||||
secret = bool(raw.get("secret"))
|
||||
choices = raw.get("choices")
|
||||
env_var = raw.get("env_var")
|
||||
|
||||
when_raw = raw.get("when")
|
||||
when: Tuple[Tuple[str, str], ...] = ()
|
||||
if isinstance(when_raw, dict):
|
||||
when = tuple((str(k), str(v)) for k, v in when_raw.items())
|
||||
|
||||
if secret:
|
||||
kind, value_type, options = KIND_SECRET, VALUE_STR, ()
|
||||
elif choices:
|
||||
kind = KIND_SELECT
|
||||
value_type = _value_type_of(default)
|
||||
options = tuple(
|
||||
ProviderFieldOption(value=str(c), label=str(c)) for c in choices
|
||||
)
|
||||
elif isinstance(default, bool):
|
||||
kind, value_type, options = KIND_BOOLEAN, VALUE_BOOL, ()
|
||||
else:
|
||||
kind, value_type, options = KIND_TEXT, _value_type_of(default), ()
|
||||
|
||||
return ProviderField(
|
||||
key=key,
|
||||
label=_label_from_key(key),
|
||||
kind=kind,
|
||||
value_type=value_type,
|
||||
default=_default_to_str(default),
|
||||
description=str(raw.get("description") or ""),
|
||||
placeholder=str(raw.get("placeholder") or ""),
|
||||
required=bool(raw.get("required")),
|
||||
url=str(raw.get("url") or ""),
|
||||
options=options,
|
||||
env_key=str(env_var) if env_var else None,
|
||||
when=when,
|
||||
)
|
||||
|
||||
|
||||
def describe_provider(
|
||||
name: str,
|
||||
schema: List[Dict[str, Any]],
|
||||
label: Optional[str] = None,
|
||||
) -> MemoryProvider:
|
||||
"""Adapt a provider's raw ``get_config_schema()`` into a normalized descriptor.
|
||||
|
||||
``schema`` is the list returned by the live provider; ``label`` overrides
|
||||
the humanized name (e.g. a provider's display name from ``plugin.yaml``).
|
||||
Unparseable entries are skipped rather than raising, so one malformed field
|
||||
can't blank out a provider's whole config surface.
|
||||
"""
|
||||
|
||||
fields: List[ProviderField] = []
|
||||
for raw in schema or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
field = _field_from_schema(raw)
|
||||
if field is not None:
|
||||
fields.append(field)
|
||||
|
||||
return MemoryProvider(
|
||||
name=name,
|
||||
label=label or _label_from_key(name),
|
||||
fields=tuple(fields),
|
||||
)
|
||||
|
||||
|
||||
def coerce_value(field: ProviderField, raw: str) -> Any:
|
||||
"""Validate + cast a submitted string back to the field's native type.
|
||||
|
||||
Raises ``ValueError`` for a select value outside its options. Empty input
|
||||
falls back to the field's declared default. Booleans accept the usual
|
||||
truthy/falsy spellings; numbers parse leniently and fall back to the
|
||||
default on a bad parse rather than corrupting the config.
|
||||
"""
|
||||
|
||||
value = (raw or "").strip()
|
||||
|
||||
if field.kind == KIND_SELECT:
|
||||
if not value:
|
||||
value = field.default
|
||||
if field.options and value not in field.allowed_values():
|
||||
raise ValueError(f"Invalid value for '{field.key}'")
|
||||
return value
|
||||
|
||||
if field.value_type == VALUE_BOOL:
|
||||
if value == "":
|
||||
value = field.default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
if field.value_type in (VALUE_INT, VALUE_FLOAT):
|
||||
if value == "":
|
||||
value = field.default
|
||||
try:
|
||||
return int(value) if field.value_type == VALUE_INT else float(value)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
return (
|
||||
int(field.default)
|
||||
if field.value_type == VALUE_INT
|
||||
else float(field.default)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return 0 if field.value_type == VALUE_INT else 0.0
|
||||
|
||||
return value or field.default
|
||||
|
||||
@@ -65,7 +65,8 @@ from hermes_cli.config import (
|
||||
from hermes_cli.memory_providers import (
|
||||
MemoryProvider,
|
||||
ProviderField,
|
||||
get_memory_provider,
|
||||
coerce_value,
|
||||
describe_provider,
|
||||
)
|
||||
from gateway.status import (
|
||||
get_running_pid,
|
||||
@@ -3172,60 +3173,86 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return config
|
||||
|
||||
|
||||
def _memory_provider_config_path(provider: MemoryProvider) -> Path:
|
||||
return get_hermes_home() / provider.name / "config.json"
|
||||
def _describe_memory_provider(name: str) -> Optional[MemoryProvider]:
|
||||
"""Load a live memory provider and normalize its declared config schema.
|
||||
|
||||
Returns ``None`` for providers that aren't discoverable or declare no
|
||||
configurable fields (e.g. built-in), so the generic panel renders nothing.
|
||||
"""
|
||||
|
||||
def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]:
|
||||
path = _memory_provider_config_path(provider)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
from plugins.memory import (
|
||||
discover_memory_providers,
|
||||
load_memory_provider,
|
||||
)
|
||||
except Exception:
|
||||
_log.warning("Failed to read memory provider config from %s", path, exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
label = next(
|
||||
(
|
||||
(desc or _label).split(".")[0].strip() or _label
|
||||
for _label, desc, _avail in [
|
||||
(n, d, a) for n, d, a in discover_memory_providers() if n == name
|
||||
]
|
||||
),
|
||||
None,
|
||||
)
|
||||
except Exception:
|
||||
label = None
|
||||
|
||||
try:
|
||||
provider = load_memory_provider(name)
|
||||
except Exception:
|
||||
provider = None
|
||||
if provider is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
schema = provider.get_config_schema() or []
|
||||
except Exception:
|
||||
_log.warning("get_config_schema() failed for memory provider %s", name, exc_info=True)
|
||||
schema = []
|
||||
|
||||
descriptor = describe_provider(name, schema, label=label)
|
||||
if not descriptor.fields:
|
||||
return None
|
||||
return descriptor
|
||||
|
||||
|
||||
def _read_provider_current(name: str) -> Dict[str, Any]:
|
||||
"""Best-effort read of a provider's persisted (non-secret) config values."""
|
||||
|
||||
try:
|
||||
from plugins.memory import load_memory_provider
|
||||
|
||||
provider = load_memory_provider(name)
|
||||
if provider is None:
|
||||
return {}
|
||||
data = provider.read_current_config()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
_log.debug("read_current_config() failed for memory provider %s", name, exc_info=True)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _read_field_value(field: ProviderField, data: Dict[str, Any]) -> str:
|
||||
"""Resolve the stored value for a non-secret field, honoring legacy reads."""
|
||||
|
||||
for source_key in (field.key, *field.aliases):
|
||||
value = data.get(source_key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
def _memory_provider_payload(descriptor: MemoryProvider) -> Dict[str, Any]:
|
||||
current = _read_provider_current(descriptor.name)
|
||||
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 _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool:
|
||||
"""Whether a secret field has a value anywhere it may have been written."""
|
||||
|
||||
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 _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]:
|
||||
data = _read_memory_provider_file(provider)
|
||||
fields: List[Dict[str, Any]] = []
|
||||
|
||||
for field in provider.fields:
|
||||
for field in descriptor.fields:
|
||||
entry: Dict[str, Any] = {
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"kind": field.kind,
|
||||
"value_type": field.value_type,
|
||||
"default": field.default,
|
||||
"description": field.description,
|
||||
"placeholder": field.placeholder,
|
||||
"required": field.required,
|
||||
"url": field.url,
|
||||
"when": [{"key": k, "value": v} for k, v in field.when],
|
||||
"options": [
|
||||
{"value": opt.value, "label": opt.label, "description": opt.description}
|
||||
for opt in field.options
|
||||
@@ -3235,83 +3262,96 @@ def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]:
|
||||
if field.is_secret:
|
||||
# Secrets are write-only over the API; only expose whether one is set.
|
||||
entry["value"] = ""
|
||||
entry["is_set"] = _field_is_set(field, data)
|
||||
entry["is_set"] = bool(field.env_key and env_on_disk.get(field.env_key))
|
||||
elif field.key in current and current[field.key] not in (None, ""):
|
||||
entry["value"] = _default_to_display(current[field.key], field)
|
||||
entry["is_set"] = True
|
||||
else:
|
||||
value = _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)
|
||||
entry["value"] = field.default
|
||||
entry["is_set"] = False
|
||||
|
||||
fields.append(entry)
|
||||
|
||||
return {"name": provider.name, "label": provider.label, "fields": fields}
|
||||
return {"name": descriptor.name, "label": descriptor.label, "fields": fields}
|
||||
|
||||
|
||||
def _coerce_field_value(field: ProviderField, raw: str) -> str:
|
||||
"""Validate and normalize a submitted non-secret value, or raise ValueError."""
|
||||
def _default_to_display(value: Any, field: ProviderField) -> str:
|
||||
"""Render a stored native value back to the string form the panel edits."""
|
||||
|
||||
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
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
@app.get("/api/memory/providers/{name}/config")
|
||||
async def get_memory_provider_config(name: str):
|
||||
provider = get_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.
|
||||
descriptor = _describe_memory_provider(name)
|
||||
if descriptor is None:
|
||||
# Undeclared/built-in providers have no config surface. Return an empty
|
||||
# schema so the generic panel simply renders nothing.
|
||||
return {"name": name, "label": name, "fields": []}
|
||||
return _memory_provider_payload(provider)
|
||||
return _memory_provider_payload(descriptor)
|
||||
|
||||
|
||||
@app.put("/api/memory/providers/{name}/config")
|
||||
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate):
|
||||
provider = get_memory_provider(name)
|
||||
if provider is None:
|
||||
descriptor = _describe_memory_provider(name)
|
||||
if descriptor is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
|
||||
|
||||
values = body.values or {}
|
||||
|
||||
try:
|
||||
existing = _read_memory_provider_file(provider)
|
||||
json_values: Dict[str, Any] = {}
|
||||
# Resolve the effective value of every field first so ``when`` clauses
|
||||
# are evaluated against what the user actually submitted (e.g. the
|
||||
# selected mode), not stale state.
|
||||
resolved: Dict[str, str] = {}
|
||||
for field in descriptor.fields:
|
||||
if field.key in values:
|
||||
resolved[field.key] = values[field.key]
|
||||
elif not field.is_secret:
|
||||
resolved[field.key] = field.default
|
||||
|
||||
config_values: Dict[str, Any] = {}
|
||||
secrets: Dict[str, str] = {}
|
||||
|
||||
for field in provider.fields:
|
||||
for field in descriptor.fields:
|
||||
# Skip fields gated off by an unmet ``when`` clause — they aren't
|
||||
# shown to the user, so don't validate or persist them.
|
||||
if not field.when_matches(resolved):
|
||||
continue
|
||||
|
||||
if field.is_secret:
|
||||
submitted = (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_field_value(field, raw)
|
||||
raw = values.get(field.key, field.default)
|
||||
config_values[field.key] = coerce_value(field, raw)
|
||||
|
||||
config = load_config()
|
||||
memory_config = config.get("memory")
|
||||
if not isinstance(memory_config, dict):
|
||||
memory_config = {}
|
||||
config["memory"] = memory_config
|
||||
memory_config["provider"] = provider.name
|
||||
memory_config["provider"] = descriptor.name
|
||||
save_config(config)
|
||||
|
||||
path = _memory_provider_config_path(provider)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing.update(json_values)
|
||||
from utils import atomic_json_write
|
||||
# Persist non-secret values through the provider's own save_config() so
|
||||
# each provider keeps ownership of its native storage layout (this is
|
||||
# the same path `hermes memory setup` uses).
|
||||
if config_values:
|
||||
try:
|
||||
from plugins.memory import load_memory_provider
|
||||
|
||||
atomic_json_write(path, existing, mode=0o600)
|
||||
provider = load_memory_provider(descriptor.name)
|
||||
if provider is not None and hasattr(provider, "save_config"):
|
||||
provider.save_config(config_values, str(get_hermes_home()))
|
||||
except Exception:
|
||||
_log.exception(
|
||||
"save_config() failed for memory provider %s", descriptor.name
|
||||
)
|
||||
|
||||
for env_key, secret in secrets.items():
|
||||
save_env_value(env_key, secret)
|
||||
|
||||
@@ -692,6 +692,20 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600)
|
||||
|
||||
def read_current_config(self):
|
||||
"""Best-effort read-back of stored config for the desktop config panel.
|
||||
|
||||
Hindsight's deepest configuration (mode-specific deps, nested bank
|
||||
layout) is owned by ``post_setup`` / ``hermes memory setup``; the
|
||||
panel round-trips the common flat fields (mode, api_url, timeouts,
|
||||
retain/recall tuning) it persists via ``save_config``.
|
||||
"""
|
||||
try:
|
||||
cfg = _load_config()
|
||||
return cfg if isinstance(cfg, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def post_setup(self, hermes_home: str, config: dict) -> None:
|
||||
"""Custom setup wizard — installs only the deps needed for the selected mode."""
|
||||
import subprocess
|
||||
|
||||
@@ -155,6 +155,22 @@ class HolographicMemoryProvider(MemoryProvider):
|
||||
{"key": "hrr_dim", "description": "HRR vector dimensions", "default": "1024"},
|
||||
]
|
||||
|
||||
def read_current_config(self):
|
||||
"""Read back values save_config() wrote under plugins.hermes-memory-store."""
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
try:
|
||||
import yaml
|
||||
config_path = Path(get_hermes_home()) / "config.yaml"
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8-sig") as f:
|
||||
existing = yaml.safe_load(f) or {}
|
||||
values = (existing.get("plugins") or {}).get("hermes-memory-store")
|
||||
return values if isinstance(values, dict) else {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
from hermes_constants import get_hermes_home
|
||||
_hermes_home = str(get_hermes_home())
|
||||
|
||||
@@ -271,6 +271,20 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
{"key": "baseUrl", "description": "Honcho base URL (for self-hosted)"},
|
||||
]
|
||||
|
||||
def read_current_config(self):
|
||||
"""Read back the non-secret values save_config() wrote to honcho.json."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
try:
|
||||
path = Path(get_hermes_home()) / "honcho.json"
|
||||
if path.exists():
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def post_setup(self, hermes_home: str, config: dict) -> None:
|
||||
"""Run the full Honcho setup wizard after provider selection."""
|
||||
import types
|
||||
|
||||
@@ -166,6 +166,12 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
|
||||
]
|
||||
|
||||
def read_current_config(self):
|
||||
try:
|
||||
return _load_config()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _get_client(self):
|
||||
"""Thread-safe client accessor with lazy initialization."""
|
||||
with self._client_lock:
|
||||
|
||||
@@ -1,46 +1,129 @@
|
||||
"""Tests for the declarative memory-provider registry."""
|
||||
"""Tests for the schema-driven memory-provider config adapter.
|
||||
|
||||
These assert the *mapping contract* (how a provider's declared
|
||||
``get_config_schema()`` becomes the normalized desktop field shape) and the
|
||||
write-time coercion — not a snapshot of any particular provider's fields, which
|
||||
are free to change.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.memory_providers import (
|
||||
KIND_BOOLEAN,
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
get_memory_provider,
|
||||
KIND_TEXT,
|
||||
VALUE_BOOL,
|
||||
VALUE_INT,
|
||||
coerce_value,
|
||||
describe_provider,
|
||||
)
|
||||
|
||||
|
||||
def test_hindsight_is_declared():
|
||||
provider = get_memory_provider("hindsight")
|
||||
|
||||
assert provider is not None
|
||||
assert provider.label == "Hindsight"
|
||||
assert {field.key for field in provider.fields} == {
|
||||
"mode",
|
||||
"api_key",
|
||||
"api_url",
|
||||
"bank_id",
|
||||
"recall_budget",
|
||||
}
|
||||
def _by_key(provider):
|
||||
return {f.key: f for f in provider.fields}
|
||||
|
||||
|
||||
def test_hindsight_mode_gating_is_expressed_as_select_options():
|
||||
provider = get_memory_provider("hindsight")
|
||||
assert provider is not None
|
||||
def test_secret_field_maps_to_secret_kind_bound_to_env():
|
||||
provider = describe_provider(
|
||||
"x",
|
||||
[{"key": "api_key", "secret": True, "env_var": "X_API_KEY", "url": "https://x"}],
|
||||
)
|
||||
field = _by_key(provider)["api_key"]
|
||||
|
||||
mode = next(field for field in provider.fields if field.key == "mode")
|
||||
assert mode.kind == KIND_SELECT
|
||||
assert mode.allowed_values() == {"cloud", "local_external"}
|
||||
# local_embedded is intentionally unsupported on desktop.
|
||||
assert "local_embedded" not in mode.allowed_values()
|
||||
assert field.kind == KIND_SECRET
|
||||
assert field.is_secret is True
|
||||
assert field.env_key == "X_API_KEY"
|
||||
assert field.url == "https://x"
|
||||
|
||||
|
||||
def test_api_key_is_a_secret_bound_to_env():
|
||||
provider = get_memory_provider("hindsight")
|
||||
assert provider is not None
|
||||
def test_choices_map_to_select_options():
|
||||
provider = describe_provider(
|
||||
"x", [{"key": "mode", "default": "cloud", "choices": ["cloud", "local"]}]
|
||||
)
|
||||
field = _by_key(provider)["mode"]
|
||||
|
||||
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"
|
||||
assert field.kind == KIND_SELECT
|
||||
assert field.allowed_values() == {"cloud", "local"}
|
||||
|
||||
|
||||
def test_unknown_provider_is_none():
|
||||
assert get_memory_provider("builtin") is None
|
||||
def test_bool_default_maps_to_boolean_kind():
|
||||
provider = describe_provider("x", [{"key": "auto", "default": True}])
|
||||
field = _by_key(provider)["auto"]
|
||||
|
||||
assert field.kind == KIND_BOOLEAN
|
||||
assert field.value_type == VALUE_BOOL
|
||||
assert field.default == "true"
|
||||
|
||||
|
||||
def test_int_default_maps_to_text_with_int_value_type():
|
||||
provider = describe_provider("x", [{"key": "tokens", "default": 4096}])
|
||||
field = _by_key(provider)["tokens"]
|
||||
|
||||
assert field.kind == KIND_TEXT
|
||||
assert field.value_type == VALUE_INT
|
||||
assert field.default == "4096"
|
||||
|
||||
|
||||
def test_when_clause_is_carried_through():
|
||||
provider = describe_provider(
|
||||
"x",
|
||||
[
|
||||
{"key": "mode", "default": "cloud", "choices": ["cloud", "local"]},
|
||||
{"key": "api_url", "default": "u", "when": {"mode": "cloud"}},
|
||||
],
|
||||
)
|
||||
api_url = _by_key(provider)["api_url"]
|
||||
|
||||
assert api_url.when == (("mode", "cloud"),)
|
||||
assert api_url.when_matches({"mode": "cloud"}) is True
|
||||
assert api_url.when_matches({"mode": "local"}) is False
|
||||
|
||||
|
||||
def test_every_field_has_a_known_kind():
|
||||
# Invariant: the adapter never emits a field the renderer can't handle.
|
||||
provider = describe_provider(
|
||||
"x",
|
||||
[
|
||||
{"key": "a", "secret": True, "env_var": "A"},
|
||||
{"key": "b", "choices": ["1", "2"]},
|
||||
{"key": "c", "default": True},
|
||||
{"key": "d", "default": "text"},
|
||||
],
|
||||
)
|
||||
known = {KIND_TEXT, KIND_SELECT, KIND_SECRET, KIND_BOOLEAN}
|
||||
|
||||
assert provider.fields # non-empty
|
||||
assert all(f.kind in known for f in provider.fields)
|
||||
|
||||
|
||||
def test_malformed_entries_are_skipped_not_fatal():
|
||||
provider = describe_provider("x", ["nope", {}, {"key": ""}, {"key": "ok"}])
|
||||
|
||||
assert _by_key(provider).keys() == {"ok"}
|
||||
|
||||
|
||||
def test_coerce_rejects_value_outside_select_options():
|
||||
provider = describe_provider("x", [{"key": "mode", "choices": ["cloud", "local"]}])
|
||||
field = _by_key(provider)["mode"]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value(field, "bogus")
|
||||
|
||||
|
||||
def test_coerce_casts_bool_and_int_to_native_types():
|
||||
provider = describe_provider(
|
||||
"x", [{"key": "auto", "default": True}, {"key": "tokens", "default": 4096}]
|
||||
)
|
||||
fields = _by_key(provider)
|
||||
|
||||
assert coerce_value(fields["auto"], "true") is True
|
||||
assert coerce_value(fields["auto"], "off") is False
|
||||
assert coerce_value(fields["tokens"], "8000") == 8000
|
||||
|
||||
|
||||
def test_coerce_empty_falls_back_to_default():
|
||||
provider = describe_provider("x", [{"key": "name", "default": "hermes"}])
|
||||
field = _by_key(provider)["name"]
|
||||
|
||||
assert coerce_value(field, "") == "hermes"
|
||||
|
||||
@@ -268,70 +268,83 @@ class TestWebServerEndpoints:
|
||||
def _provider_field_map(payload):
|
||||
return {field["key"]: field for field in payload["fields"]}
|
||||
|
||||
def test_get_memory_provider_config_returns_safe_defaults(self):
|
||||
resp = self.client.get("/api/memory/providers/hindsight/config")
|
||||
# The desktop memory-provider config surface is schema-driven: the endpoint
|
||||
# derives fields from each provider's live get_config_schema(). These tests
|
||||
# assert that contract against a real bundled provider (mem0 — small, stable
|
||||
# schema) and Hindsight's when-gating, not a snapshot of a hardcoded list.
|
||||
|
||||
def test_get_memory_provider_config_derives_fields_from_schema(self):
|
||||
resp = self.client.get("/api/memory/providers/mem0/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "hindsight"
|
||||
assert data["label"] == "Hindsight"
|
||||
assert data["name"] == "mem0"
|
||||
|
||||
fields = self._provider_field_map(data)
|
||||
assert fields["mode"]["kind"] == "select"
|
||||
assert fields["mode"]["value"] == "cloud"
|
||||
assert {opt["value"] for opt in fields["mode"]["options"]} == {"cloud", "local_external"}
|
||||
assert fields["api_url"]["value"] == "https://api.hindsight.vectorize.io"
|
||||
assert fields["bank_id"]["value"] == "hermes"
|
||||
assert fields["recall_budget"]["value"] == "mid"
|
||||
# Declared fields surface generically with their mapped kinds.
|
||||
assert fields["api_key"]["kind"] == "secret"
|
||||
assert fields["api_key"]["is_set"] is False
|
||||
assert fields["api_key"]["value"] == ""
|
||||
assert fields["rerank"]["kind"] == "select"
|
||||
assert {opt["value"] for opt in fields["rerank"]["options"]} == {"true", "false"}
|
||||
assert "user_id" in fields and "agent_id" in fields
|
||||
|
||||
def test_put_memory_provider_config_writes_config_and_secret(self):
|
||||
def test_put_memory_provider_config_writes_via_save_config_and_secret(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import load_config, load_env
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/hindsight/config",
|
||||
"/api/memory/providers/mem0/config",
|
||||
json={
|
||||
"values": {
|
||||
"mode": "local_external",
|
||||
"api_url": "http://localhost:8888",
|
||||
"api_key": "hs-test-key",
|
||||
"bank_id": "ben-bank",
|
||||
"recall_budget": "high",
|
||||
"user_id": "ben",
|
||||
"agent_id": "agent-x",
|
||||
"rerank": "false",
|
||||
"api_key": "mem0-test-key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
assert load_config()["memory"]["provider"] == "hindsight"
|
||||
assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key"
|
||||
# Provider is activated and the secret lands in the env store.
|
||||
assert load_config()["memory"]["provider"] == "mem0"
|
||||
assert load_env()["MEM0_API_KEY"] == "mem0-test-key"
|
||||
|
||||
config_path = get_hermes_home() / "hindsight" / "config.json"
|
||||
# Non-secret values are persisted through the provider's own save_config()
|
||||
# to its native location (mem0.json), not a path the endpoint hardcodes.
|
||||
config_path = get_hermes_home() / "mem0.json"
|
||||
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert provider_config == {
|
||||
"mode": "local_external",
|
||||
"api_url": "http://localhost:8888",
|
||||
"bank_id": "ben-bank",
|
||||
"recall_budget": "high",
|
||||
}
|
||||
assert provider_config["user_id"] == "ben"
|
||||
assert provider_config["agent_id"] == "agent-x"
|
||||
assert provider_config["rerank"] == "false"
|
||||
assert "api_key" not in provider_config # secret never written to the config file
|
||||
|
||||
def test_put_memory_provider_config_rejects_unsupported_select_value(self):
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/hindsight/config",
|
||||
json={
|
||||
"values": {
|
||||
"mode": "local_embedded",
|
||||
"api_url": "http://localhost:8888",
|
||||
"bank_id": "hermes",
|
||||
"recall_budget": "mid",
|
||||
}
|
||||
},
|
||||
"/api/memory/providers/mem0/config",
|
||||
json={"values": {"rerank": "maybe"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_put_skips_fields_gated_off_by_unmet_when_clause(self):
|
||||
# Hindsight's llm_* fields are gated `when mode == local_embedded`.
|
||||
# Saving in cloud mode must not persist them.
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/hindsight/config",
|
||||
json={"values": {"mode": "cloud"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
config_path = get_hermes_home() / "hindsight" / "config.json"
|
||||
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert provider_config["mode"] == "cloud"
|
||||
assert "llm_model" not in provider_config
|
||||
assert "llm_provider" not in provider_config
|
||||
|
||||
def test_put_unknown_memory_provider_returns_404(self):
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/nope/config", json={"values": {}}
|
||||
@@ -347,19 +360,11 @@ class TestWebServerEndpoints:
|
||||
|
||||
def test_get_memory_provider_config_does_not_return_secret(self):
|
||||
self.client.put(
|
||||
"/api/memory/providers/hindsight/config",
|
||||
json={
|
||||
"values": {
|
||||
"mode": "cloud",
|
||||
"api_url": "https://api.hindsight.vectorize.io",
|
||||
"api_key": "secret-value",
|
||||
"bank_id": "hermes",
|
||||
"recall_budget": "mid",
|
||||
}
|
||||
},
|
||||
"/api/memory/providers/mem0/config",
|
||||
json={"values": {"api_key": "secret-value", "user_id": "ben"}},
|
||||
)
|
||||
|
||||
resp = self.client.get("/api/memory/providers/hindsight/config")
|
||||
resp = self.client.get("/api/memory/providers/mem0/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
Reference in New Issue
Block a user