mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-20 10:11:58 +00:00
fix(desktop): show Hindsight memory provider (#37546)
* fix(desktop): show Hindsight memory provider
* feat(desktop): configure Hindsight memory provider
* fix(desktop): limit Hindsight modes to supported setup
* refactor(desktop): generic memory-provider config surface
Replace the bespoke Hindsight settings surface with a declarative,
schema-driven path so adding a memory provider is pure declaration —
no per-provider page, conditional, or endpoint.
- memory_providers.py: declarative registry. Each provider lists its
fields {key, label, kind, default, options, secret-vs-plain}. Hindsight's
mode is a select(cloud, local_external), so rejecting local_embedded
falls out of generic enum validation instead of a hand-written check.
- One generic endpoint pair GET/PUT /api/memory/providers/{name}/config.
GET returns declared fields + current values (secrets only as is_set,
never read back); PUT validates selects against their options, writes
plain fields to the provider config file, secrets to the env store,
and flips memory.provider.
- ProviderConfigPanel renders straight from the schema, replacing
hindsight-settings.tsx and the memory.provider === 'hindsight'
conditional in config-settings.tsx — same pattern as
toolset-config-panel.tsx off env_vars.
Scoped to memory providers; storage layout is unchanged so the runtime
Hindsight plugin reads the same config.json / HINDSIGHT_API_KEY / provider
keys as before. Tests cover the registry, endpoint behavior (defaults,
write+secret, select rejection, unknown provider, secret-never-returned),
and the generic panel.
This commit is contained in:
parent
cbe44bf890
commit
03d9a95a74
11 changed files with 841 additions and 2 deletions
|
|
@ -23,6 +23,7 @@ import { fieldCopyForSchemaKey } from './field-copy'
|
|||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import { ProviderConfigPanel } from './provider-config-panel'
|
||||
|
||||
function ConfigField({
|
||||
schemaKey,
|
||||
|
|
@ -368,6 +369,9 @@ 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))} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ 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', 'honcho'],
|
||||
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
|
||||
// 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,6 +6,12 @@ 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', '', {})
|
||||
|
||||
expect(options).toContain('hindsight')
|
||||
})
|
||||
|
||||
describe('defineFieldCopy', () => {
|
||||
it('flattens nested field copy paths', () => {
|
||||
const copy = defineFieldCopy({
|
||||
|
|
|
|||
142
apps/desktop/src/app/settings/provider-config-panel.test.tsx
Normal file
142
apps/desktop/src/app/settings/provider-config-panel.test.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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/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')
|
||||
|
||||
return render(<ProviderConfigPanel provider={provider} />)
|
||||
}
|
||||
|
||||
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()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
|
||||
expect(screen.queryByLabelText('API URL')).toBeNull()
|
||||
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')
|
||||
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()
|
||||
})
|
||||
})
|
||||
182
apps/desktop/src/app/settings/provider-config-panel.tsx
Normal file
182
apps/desktop/src/app/settings/provider-config-panel.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
MemoryProviderConfig,
|
||||
MessagingPlatformsResponse,
|
||||
MessagingPlatformTestResponse,
|
||||
MessagingPlatformUpdate,
|
||||
|
|
@ -71,6 +72,7 @@ export type {
|
|||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
MemoryProviderConfig,
|
||||
MessagingEnvVarInfo,
|
||||
MessagingHomeChannel,
|
||||
MessagingPlatformInfo,
|
||||
|
|
@ -339,6 +341,23 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
|
|||
})
|
||||
}
|
||||
|
||||
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
|
||||
return window.hermesDesktop.api<MemoryProviderConfig>({
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMemoryProviderConfig(
|
||||
provider: string,
|
||||
values: Record<string, string>
|
||||
): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`,
|
||||
method: 'PUT',
|
||||
body: { values }
|
||||
})
|
||||
}
|
||||
|
||||
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
|
||||
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
|
||||
...profileScoped(),
|
||||
|
|
|
|||
|
|
@ -113,6 +113,31 @@ export interface EnvVarInfo {
|
|||
url: null | string
|
||||
}
|
||||
|
||||
export type MemoryProviderFieldKind = 'secret' | 'select' | 'text'
|
||||
|
||||
export interface MemoryProviderFieldOption {
|
||||
description: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MemoryProviderField {
|
||||
description: string
|
||||
is_set: boolean
|
||||
key: string
|
||||
kind: MemoryProviderFieldKind
|
||||
label: string
|
||||
options: MemoryProviderFieldOption[]
|
||||
placeholder: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MemoryProviderConfig {
|
||||
fields: MemoryProviderField[]
|
||||
label: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MessagingEnvVarInfo {
|
||||
advanced: boolean
|
||||
description: string
|
||||
|
|
|
|||
149
hermes_cli/memory_providers.py
Normal file
149
hermes_cli/memory_providers.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""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)
|
||||
|
|
@ -62,6 +62,11 @@ from hermes_cli.config import (
|
|||
recommended_update_command_for_method,
|
||||
redact_key,
|
||||
)
|
||||
from hermes_cli.memory_providers import (
|
||||
MemoryProvider,
|
||||
ProviderField,
|
||||
get_memory_provider,
|
||||
)
|
||||
from gateway.status import (
|
||||
get_running_pid,
|
||||
get_runtime_status_running_pid,
|
||||
|
|
@ -673,6 +678,10 @@ class EnvVarReveal(BaseModel):
|
|||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class MemoryProviderConfigUpdate(BaseModel):
|
||||
values: Dict[str, str] = {}
|
||||
|
||||
|
||||
class MessagingPlatformUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
env: Dict[str, str] = {}
|
||||
|
|
@ -3163,6 +3172,160 @@ 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 _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"))
|
||||
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_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)
|
||||
|
||||
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:
|
||||
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"] = _field_is_set(field, data)
|
||||
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)
|
||||
|
||||
fields.append(entry)
|
||||
|
||||
return {"name": provider.name, "label": provider.label, "fields": fields}
|
||||
|
||||
|
||||
def _coerce_field_value(field: ProviderField, raw: str) -> str:
|
||||
"""Validate and normalize a submitted non-secret value, or raise ValueError."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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.
|
||||
return {"name": name, "label": name, "fields": []}
|
||||
return _memory_provider_payload(provider)
|
||||
|
||||
|
||||
@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:
|
||||
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] = {}
|
||||
secrets: Dict[str, str] = {}
|
||||
|
||||
for field in provider.fields:
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
atomic_json_write(path, existing, mode=0o600)
|
||||
|
||||
for env_key, secret in secrets.items():
|
||||
save_env_value(env_key, secret)
|
||||
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
_log.exception("PUT /api/memory/providers/%s/config failed", name)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config(profile: Optional[str] = None):
|
||||
with _profile_scope(profile):
|
||||
|
|
|
|||
46
tests/hermes_cli/test_memory_providers.py
Normal file
46
tests/hermes_cli/test_memory_providers.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Tests for the declarative memory-provider registry."""
|
||||
|
||||
from hermes_cli.memory_providers import (
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
get_memory_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 test_hindsight_mode_gating_is_expressed_as_select_options():
|
||||
provider = get_memory_provider("hindsight")
|
||||
assert provider is not None
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def test_api_key_is_a_secret_bound_to_env():
|
||||
provider = get_memory_provider("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
|
||||
|
|
@ -264,6 +264,110 @@ class TestWebServerEndpoints:
|
|||
|
||||
assert web_server._dashboard_local_update_managed_externally() is True
|
||||
|
||||
@staticmethod
|
||||
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")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "hindsight"
|
||||
assert data["label"] == "Hindsight"
|
||||
|
||||
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"
|
||||
assert fields["api_key"]["kind"] == "secret"
|
||||
assert fields["api_key"]["is_set"] is False
|
||||
|
||||
def test_put_memory_provider_config_writes_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",
|
||||
json={
|
||||
"values": {
|
||||
"mode": "local_external",
|
||||
"api_url": "http://localhost:8888",
|
||||
"api_key": "hs-test-key",
|
||||
"bank_id": "ben-bank",
|
||||
"recall_budget": "high",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
config_path = get_hermes_home() / "hindsight" / "config.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",
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_put_unknown_memory_provider_returns_404(self):
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/nope/config", json={"values": {}}
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_unknown_memory_provider_returns_empty_schema(self):
|
||||
resp = self.client.get("/api/memory/providers/builtin/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["fields"] == []
|
||||
|
||||
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",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
resp = self.client.get("/api/memory/providers/hindsight/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
fields = self._provider_field_map(data)
|
||||
assert fields["api_key"]["is_set"] is True
|
||||
assert fields["api_key"]["value"] == ""
|
||||
assert "secret-value" not in json.dumps(data)
|
||||
|
||||
# ── GET /api/media (remote image display) ───────────────────────────
|
||||
|
||||
def test_get_media_serves_image_in_root(self):
|
||||
|
|
@ -377,7 +481,6 @@ class TestWebServerEndpoints:
|
|||
assert config["dashboard"]["theme"] == "ember"
|
||||
assert config["dashboard"]["font"] == "jetbrains-mono"
|
||||
|
||||
|
||||
def test_get_sessions_uses_only_persisted_cwd(self, monkeypatch):
|
||||
"""Session rows without persisted cwd must not inherit TERMINAL_CWD.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue