diff --git a/app/.env.example b/app/.env.example
index 7bd197cbd..068f8dc8e 100644
--- a/app/.env.example
+++ b/app/.env.example
@@ -77,6 +77,15 @@ VITE_DEV_FORCE_ONBOARDING=false
# core stops responding. Bounded [1000, 600000]; default 30000.
# VITE_CORE_RPC_TIMEOUT_MS=30000
+# [optional] Vite dev-server port for `pnpm dev:app:win` (read by both
+# vite.config.ts and scripts/run-dev-win.sh). Default 1420. Override per
+# worktree to avoid the hardcoded-1420 collision when running multiple
+# dev:app:win sessions in parallel. The HMR companion websocket binds
+# to (port + 1), so leave a 2-port gap between concurrent worktrees:
+# 1420, 1422, 1424, … When set to non-1420 the dev script also adds an
+# inline `tauri dev -c '{"build":{"devUrl":...}}'` override.
+# OPENHUMAN_DEV_PORT=1422
+
# [optional] Minimum desktop app semver to complete OAuth deep links (openhuman://oauth/success). Leave unset in dev.
# VITE_MINIMUM_SUPPORTED_APP_VERSION=0.51.0
# [optional] Download page when OAuth is blocked due to an outdated build (default: GitHub releases/latest).
diff --git a/app/src/components/intelligence/BackendChooser.tsx b/app/src/components/intelligence/BackendChooser.tsx
deleted file mode 100644
index 2356365ce..000000000
--- a/app/src/components/intelligence/BackendChooser.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-import { useState } from 'react';
-
-import type { Backend } from '../../lib/intelligence/settingsApi';
-
-interface BackendChooserProps {
- /** Currently selected backend. */
- value: Backend;
- /** Called when the user clicks a different card. */
- onChange: (next: Backend) => void;
- /** Optional cloud-cost estimate. Mock value until cost-tracker hook lands. */
- costEstimate?: string;
- /** Disabled while a backend switch is in flight. */
- busy?: boolean;
-}
-
-/**
- * Two large cards — Cloud (default, recommended) vs Local (advanced).
- *
- * Visual style intentionally matches the rest of the Intelligence page:
- * `bg-white` + `border-stone-200` + `rounded-2xl`, primary blue for the
- * selected accent. The inline tokens from the brief
- * (paper, hairline, ocean) map onto the existing stone/primary scale —
- * we keep the existing scale to avoid forking the design system.
- */
-export default function BackendChooser({
- value,
- onChange,
- costEstimate = '$0.42 / mo est.',
- busy = false,
-}: BackendChooserProps) {
- const [hoveredCloud, setHoveredCloud] = useState(false);
-
- const cardBase =
- 'flex-1 min-h-[160px] px-6 py-5 rounded-2xl text-left transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed';
-
- return (
-
- {/* Cloud */}
-
-
- {/* Local */}
-
-
- );
-}
-
-function RadioDot({ active }: { active: boolean }) {
- return (
-
-
-
- );
-}
diff --git a/app/src/components/intelligence/IntelligenceSettingsTab.tsx b/app/src/components/intelligence/IntelligenceSettingsTab.tsx
deleted file mode 100644
index 43bf66304..000000000
--- a/app/src/components/intelligence/IntelligenceSettingsTab.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-
-import {
- type Backend,
- capabilityForModel,
- DEFAULT_EXTRACT_MODEL,
- downloadAsset,
- fetchInstalledModels,
- getMemoryTreeLlm,
- type ModelDescriptor,
- REQUIRED_EMBEDDER_MODEL,
- setMemoryTreeLlm,
-} from '../../lib/intelligence/settingsApi';
-import BackendChooser from './BackendChooser';
-import ModelAssignment from './ModelAssignment';
-import ModelCatalog from './ModelCatalog';
-
-/**
- * Settings tab for the Intelligence page.
- *
- * Layout (top → bottom):
- * 1. AI Backend — Cloud / Local toggle
- * 2. Model Assignment — per-role dropdowns (visible only in Local mode)
- * 3. Model Catalog — full curated list with download / use / delete
- * 4. Currently Loaded — live `/api/ps`-style readout
- *
- * The orchestrator owns the cross-section state (backend, role assignments,
- * cached installed-models / status). Sections themselves stay presentational.
- */
-export default function IntelligenceSettingsTab() {
- const [backend, setBackend] = useState('cloud');
- const [backendBusy, setBackendBusy] = useState(false);
- // Single Memory LLM that drives both extractor and summariser. Most
- // users want one model for both; the rare case of mixing them is not
- // worth the second dropdown's cognitive cost.
- const [memoryModel, setMemoryModel] = useState(DEFAULT_EXTRACT_MODEL);
- const [installedModels, setInstalledModels] = useState([]);
-
- // One-shot bootstrap — pull current backend and the installed-model list.
- useEffect(() => {
- let cancelled = false;
- void (async () => {
- try {
- console.debug('[intelligence-settings] bootstrap');
- const [bk, models] = await Promise.all([getMemoryTreeLlm(), fetchInstalledModels()]);
- if (cancelled) return;
- setBackend(bk);
- setInstalledModels(models.map(m => m.name));
- } catch (err) {
- if (!cancelled) {
- // Bootstrap failure leaves the tab on its useState defaults
- // (cloud backend, empty installed list) rather than throwing
- // an unhandled rejection. The user can still flip the backend
- // chooser; subsequent reads will retry the RPCs.
- console.error('[intelligence-settings] bootstrap failed', err);
- }
- }
- })();
- return () => {
- cancelled = true;
- };
- }, []);
-
- const handleBackendChange = useCallback(async (next: Backend) => {
- setBackendBusy(true);
- try {
- const { effective } = await setMemoryTreeLlm(next);
- setBackend(effective);
- } catch (err) {
- console.error('[intelligence-settings] backend switch failed', err);
- } finally {
- setBackendBusy(false);
- }
- }, []);
-
- // Persist Memory LLM changes to config.toml. Fans out to both
- // extractor and summariser keys in a single atomic write — the unified
- // UI is one dropdown, but the underlying schema retains both keys so
- // power users can still split them via the RPC directly if needed.
- const handleMemoryModelChange = useCallback(
- async (id: string) => {
- console.debug('[intelligence-settings] memory model -> %s', id);
- const previous = memoryModel;
- setMemoryModel(id);
- try {
- await setMemoryTreeLlm('local', { extractModel: id, summariserModel: id });
- } catch (err) {
- // Persistence failed → roll back the optimistic UI update so the
- // dropdown reflects the value that's actually saved on disk
- // rather than the one the user just attempted.
- setMemoryModel(previous);
- console.error('[intelligence-settings] persist memory model failed', err);
- }
- },
- [memoryModel]
- );
-
- const handleDownload = useCallback(async (model: ModelDescriptor) => {
- const cap = capabilityForModel(model);
- if (!cap) {
- console.debug('[intelligence-settings] no capability for model', { id: model.id });
- return;
- }
- try {
- await downloadAsset(cap);
- } catch (err) {
- console.error('[intelligence-settings] model download failed', err);
- } finally {
- // Refresh installed list after any download attempt — even on
- // failure, Ollama may have partially landed assets we should
- // surface; if it hasn't, the next bootstrap tick will catch up.
- const refreshed = await fetchInstalledModels();
- setInstalledModels(refreshed.map(m => m.name));
- }
- }, []);
-
- const handleUse = useCallback(
- (model: ModelDescriptor) => {
- if (model.roles.includes('extract') || model.roles.includes('summariser')) {
- void handleMemoryModelChange(model.id);
- }
- },
- [handleMemoryModelChange]
- );
-
- const activeModelIds = useMemo(() => {
- const ids = new Set();
- ids.add(memoryModel);
- ids.add(REQUIRED_EMBEDDER_MODEL);
- return [...ids];
- }, [memoryModel]);
-
- return (
-
-
-
-
-
- {/* All local-model sections (assignment, catalog, currently-loaded)
- are gated on local backend. Cloud users get just the backend
- chooser + the explanatory copy that lives inside it — they don't
- need to see Ollama-related UI at all. */}
- {backend === 'local' && (
- <>
-
-
-
-
-
-
-
- >
- )}
-
- {children}
-
- );
-}
diff --git a/app/src/components/intelligence/ModelAssignment.tsx b/app/src/components/intelligence/ModelAssignment.tsx
deleted file mode 100644
index d2b0ec5da..000000000
--- a/app/src/components/intelligence/ModelAssignment.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import {
- DEFAULT_EXTRACT_MODEL,
- DEFAULT_SUMMARISER_MODEL,
- type ModelDescriptor,
- RECOMMENDED_MODEL_CATALOG,
- REQUIRED_EMBEDDER_MODEL,
-} from '../../lib/intelligence/settingsApi';
-
-interface ModelAssignmentProps {
- /** Names of models that are already installed on the user's machine. */
- installedModelIds: ReadonlyArray;
- /** Currently chosen memory LLM (used for both extract + summarise). */
- memoryModel: string;
- /** Called when the user picks a different memory LLM. The setting fans
- * out to both `llm_extractor_model` and `llm_summariser_model` in
- * config.toml — most users want one model for both roles, and the
- * cognitive load of two dropdowns isn't worth the rare power-user
- * case of mixing them. */
- onChangeMemory: (id: string) => void;
-}
-
-/**
- * Per-role assignment table — two rows: Memory LLM (covers both extract
- * and summarise), and Embedder.
- *
- * The embedder row is locked to `bge-m3` for v1 (the spec says we never
- * round-trip embeddings through the cloud). The Memory LLM dropdown is
- * populated from the recommended catalog filtered to models that can
- * serve both extract AND summarise roles, plus any locally-installed
- * models the user has pulled outside the curated catalog.
- */
-export default function ModelAssignment({
- installedModelIds,
- memoryModel,
- onChangeMemory,
-}: ModelAssignmentProps) {
- // Ollama returns tags as `:latest` for default-tag models. The
- // catalog stores bare names (e.g. `bge-m3`). Strip the `:latest` suffix
- // on the installed side so the bare-name comparison matches.
- const normalizedInstalled = installedModelIds.map(id =>
- id.endsWith(':latest') ? id.slice(0, -':latest'.length) : id
- );
- const memoryOptions = memoryLlmOptions(normalizedInstalled);
- const embedderDescriptor = RECOMMENDED_MODEL_CATALOG.find(m => m.id === REQUIRED_EMBEDDER_MODEL);
- const embedderInstalled = normalizedInstalled.includes(REQUIRED_EMBEDDER_MODEL);
-
- return (
-
- );
-}
-
-function describeMemory(model?: ModelDescriptor): string {
- if (!model) return 'used for extract + summarise';
- return `${model.size} · ${model.ramHint} · ${model.category}`;
-}
-
-/**
- * Build the Memory LLM dropdown options. A model qualifies if it can serve
- * BOTH extract and summarise roles. Catalog entries come first; locally
- * installed extras (pulled outside the curated catalog) are appended so
- * they remain selectable.
- */
-function memoryLlmOptions(installedModelIds: ReadonlyArray): ModelDescriptor[] {
- const catalog = RECOMMENDED_MODEL_CATALOG.filter(
- m => m.roles.includes('extract') && m.roles.includes('summariser')
- );
- const known = new Set(catalog.map(m => m.id));
- const extras = installedModelIds
- .filter(id => !known.has(id) && id !== REQUIRED_EMBEDDER_MODEL)
- .map(id => ({
- id,
- size: '—',
- approxBytes: 0,
- ramHint: '—',
- category: 'balanced',
- note: 'locally installed',
- roles: ['extract', 'summariser'],
- }));
- return [...catalog, ...extras];
-}
-
-// Re-export defaults so callers can still seed initial state via these
-// constants without chasing them through the API module.
-export { DEFAULT_EXTRACT_MODEL, DEFAULT_SUMMARISER_MODEL, REQUIRED_EMBEDDER_MODEL };
diff --git a/app/src/components/intelligence/ModelCatalog.tsx b/app/src/components/intelligence/ModelCatalog.tsx
deleted file mode 100644
index 005a8d3d9..000000000
--- a/app/src/components/intelligence/ModelCatalog.tsx
+++ /dev/null
@@ -1,252 +0,0 @@
-import { useState } from 'react';
-
-import {
- capabilityForModel,
- type ModelDescriptor,
- RECOMMENDED_MODEL_CATALOG,
-} from '../../lib/intelligence/settingsApi';
-
-interface ModelCatalogProps {
- /** Names of models that are already installed on the user's machine. */
- installedModelIds: ReadonlyArray;
- /** Models in active use right now (assigned to a role). */
- activeModelIds: ReadonlyArray;
- /** Called when the user kicks off a download for a catalog entry. */
- onDownload: (model: ModelDescriptor) => Promise;
- /** Called when the user wants to assign an installed model to its role. */
- onUse: (model: ModelDescriptor) => void;
- /** Called when the user removes an installed model. */
- onDelete?: (model: ModelDescriptor) => Promise;
-}
-
-type RowState = 'idle' | 'downloading' | 'error';
-
-/**
- * Single-column list of curated models. Each row is one card showing
- * [action]
- * The action button changes by state:
- * - not installed → "Download" (clicks fire the per-capability RPC)
- * - installed but unused → "Use"
- * - installed and active → "Active"
- * - downloading → inline progress bar (mocked client-side animation
- * since the per-asset RPC is fire-and-forget; the real progress
- * stream is wired in `local_ai_downloads_progress` polling — out of
- * scope for v1)
- */
-// Ollama reports tags as `:` (e.g. `bge-m3:latest`,
-// `gemma3:1b-it-qat`). The recommended catalog uses bare names for the
-// default-`:latest` case (e.g. `bge-m3`) and full `:` for
-// non-default tags. Normalize both sides by stripping the `:latest`
-// suffix before comparing — that way `bge-m3` matches `bge-m3:latest`,
-// while `gemma3:1b-it-qat` still requires the explicit tag.
-function normalizeModelId(id: string): string {
- return id.endsWith(':latest') ? id.slice(0, -':latest'.length) : id;
-}
-
-export default function ModelCatalog({
- installedModelIds,
- activeModelIds,
- onDownload,
- onUse,
- onDelete,
-}: ModelCatalogProps) {
- const installedSet = new Set(installedModelIds.map(normalizeModelId));
- const activeSet = new Set(activeModelIds.map(normalizeModelId));
-
- return (
-
- );
-}
diff --git a/app/src/components/intelligence/__tests__/IntelligenceSettingsTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceSettingsTab.test.tsx
deleted file mode 100644
index eb6a6a626..000000000
--- a/app/src/components/intelligence/__tests__/IntelligenceSettingsTab.test.tsx
+++ /dev/null
@@ -1,258 +0,0 @@
-import { fireEvent, screen, waitFor } from '@testing-library/react';
-import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
-
-import { renderWithProviders } from '../../../test/test-utils';
-import IntelligenceSettingsTab from '../IntelligenceSettingsTab';
-
-// The orchestrator hits these RPCs on mount; the global tauriCommands mock
-// in setup.ts only stubs auth/service helpers, so we extend it here with
-// the local-AI surface the Settings tab uses, plus the new memory_tree
-// LLM-selector RPCs that replaced the dev-time mock backend.
-vi.mock('../../../utils/tauriCommands', () => ({
- isTauri: vi.fn(() => true),
- // memory_tree LLM selector — the BackendChooser polls these on mount and
- // again on every backend toggle. We track the value in a closure so the
- // set→get round-trip behaves like the real persistent core.
- memoryTreeGetLlm: vi.fn(),
- memoryTreeSetLlm: vi.fn(),
- openhumanLocalAiAssetsStatus: vi
- .fn()
- .mockResolvedValue({
- result: {
- chat: { state: 'NotInstalled', id: '', provider: 'ollama' },
- vision: { state: 'NotInstalled', id: '', provider: 'ollama' },
- embedding: { state: 'NotInstalled', id: '', provider: 'ollama' },
- stt: { state: 'NotInstalled', id: '', provider: 'ollama' },
- tts: { state: 'NotInstalled', id: '', provider: 'ollama' },
- quantization: 'q4_k_m',
- },
- }),
- openhumanLocalAiDiagnostics: vi.fn().mockResolvedValue({
- ollama_running: true,
- ollama_binary_path: '/usr/local/bin/ollama',
- installed_models: [
- { name: 'gemma3:1b-it-qat', size: 1_700_000_000, modified_at: null },
- { name: 'bge-m3', size: 1_300_000_000, modified_at: null },
- ],
- expected: {
- chat_model: 'gemma3:1b-it-qat',
- chat_found: true,
- embedding_model: 'bge-m3',
- embedding_found: true,
- vision_model: '',
- vision_found: false,
- },
- issues: [],
- ok: true,
- }),
- openhumanLocalAiStatus: vi
- .fn()
- .mockResolvedValue({
- result: {
- state: 'Ready',
- model_id: 'gemma3:1b-it-qat',
- chat_model_id: 'gemma3:1b-it-qat',
- vision_model_id: '',
- embedding_model_id: 'bge-m3',
- stt_model_id: '',
- tts_voice_id: '',
- quantization: 'q4_k_m',
- vision_state: 'idle',
- vision_mode: 'off',
- embedding_state: 'Ready',
- stt_state: 'idle',
- tts_state: 'idle',
- provider: 'ollama',
- active_backend: 'cpu',
- last_latency_ms: 142,
- },
- }),
- openhumanLocalAiPresets: vi
- .fn()
- .mockResolvedValue({
- presets: [],
- recommended_tier: 'minimal',
- current_tier: 'minimal',
- device: {
- total_ram_bytes: 16_000_000_000,
- cpu_count: 8,
- cpu_brand: 'Test CPU',
- os_name: 'macos',
- os_version: '14',
- has_gpu: false,
- gpu_description: null,
- },
- local_ai_enabled: false,
- }),
- openhumanLocalAiDownloadAsset: vi
- .fn()
- .mockResolvedValue({
- result: {
- chat: { state: 'Ready', id: 'gemma3:1b-it-qat', provider: 'ollama' },
- vision: { state: 'NotInstalled', id: '', provider: 'ollama' },
- embedding: { state: 'Ready', id: 'bge-m3', provider: 'ollama' },
- stt: { state: 'NotInstalled', id: '', provider: 'ollama' },
- tts: { state: 'NotInstalled', id: '', provider: 'ollama' },
- quantization: 'q4_k_m',
- },
- }),
-}));
-
-// Pull mocked references after vi.mock() has hoisted. Cast through unknown
-// because the import here is the typed wrapper module shape.
-const { memoryTreeGetLlm, memoryTreeSetLlm } =
- (await import('../../../utils/tauriCommands')) as unknown as {
- memoryTreeGetLlm: Mock;
- memoryTreeSetLlm: Mock;
- };
-
-describe('IntelligenceSettingsTab', () => {
- beforeEach(() => {
- let backend: 'cloud' | 'local' = 'cloud';
- memoryTreeGetLlm.mockReset();
- memoryTreeSetLlm.mockReset();
- memoryTreeGetLlm.mockImplementation(async () => ({ current: backend }));
- // Accept both legacy (bare string) and the new request-object shape so
- // tests can assert on either call form.
- memoryTreeSetLlm.mockImplementation(
- async (req: 'cloud' | 'local' | { backend: 'cloud' | 'local' }) => {
- backend = typeof req === 'string' ? req : req.backend;
- return { current: backend };
- }
- );
- });
-
- // Helper: bootstrap into Local mode so the model assignment + catalog
- // render. Cloud is the default; clicking the Advanced radio flips to
- // local and renders the Ollama-related sections.
- async function flipToLocal() {
- await waitFor(() => {
- expect(screen.getByText('AI backend')).toBeInTheDocument();
- });
- const radios = screen.getAllByRole('radio');
- const localCard = radios.find(el => /Advanced/.test(el.textContent ?? ''));
- expect(localCard).toBeDefined();
- fireEvent.click(localCard!);
- await waitFor(() => {
- expect(screen.getByText('Model assignment')).toBeInTheDocument();
- });
- }
-
- it('renders the AI backend section in cloud mode (no local sections)', async () => {
- renderWithProviders();
-
- await waitFor(() => {
- expect(screen.getByText('AI backend')).toBeInTheDocument();
- });
- // Cloud is default — local-only sections are hidden so cloud users
- // never see Ollama-related UI.
- expect(screen.queryByText('Model assignment')).not.toBeInTheDocument();
- expect(screen.queryByText('Model catalog')).not.toBeInTheDocument();
- // Currently-loaded panel was removed entirely (was dev-debug noise).
- expect(screen.queryByText('Currently loaded')).not.toBeInTheDocument();
- });
-
- it('hides Model assignment in Cloud mode and reveals it in Local mode', async () => {
- renderWithProviders();
- await flipToLocal();
-
- // The new UI consolidates Extract + Summariser LLM into a single
- // Memory LLM picker (the underlying RPC still fans out to both
- // extract_model and summariser_model in config.toml).
- expect(screen.getByText('Memory LLM')).toBeInTheDocument();
- expect(screen.getByText('Embedder')).toBeInTheDocument();
- // Old separate dropdowns must be absent.
- expect(screen.queryByText('Extract LLM')).not.toBeInTheDocument();
- expect(screen.queryByText('Summariser LLM')).not.toBeInTheDocument();
- });
-
- it('shows model catalog rows with sizes (in local mode)', async () => {
- renderWithProviders();
- await flipToLocal();
-
- await waitFor(() => {
- expect(screen.getAllByText('qwen2.5:0.5b').length).toBeGreaterThanOrEqual(1);
- });
- // Each model can appear in the Memory LLM dropdown AND the catalog,
- // so use getAllByText. Just confirm the catalog has at least one of
- // each curated entry rendered somewhere on the screen.
- expect(screen.getAllByText('gemma3:1b-it-qat').length).toBeGreaterThanOrEqual(1);
- expect(screen.getAllByText('gemma3:4b').length).toBeGreaterThanOrEqual(1);
- expect(screen.getAllByText('gemma3:12b-it-qat').length).toBeGreaterThanOrEqual(1);
- expect(screen.getAllByText('bge-m3').length).toBeGreaterThanOrEqual(1);
-
- // 3.3 GB is unique to gemma3:4b in the catalog row meta.
- expect(screen.getByText('3.3 GB')).toBeInTheDocument();
- });
-
- it('renders a Download action for models that are not installed', async () => {
- renderWithProviders();
- await flipToLocal();
-
- // qwen2.5:0.5b is NOT in the diagnostics installed list, so it shows
- // a Download button.
- await waitFor(() => {
- expect(screen.getByText('qwen2.5:0.5b')).toBeInTheDocument();
- });
-
- const downloadButtons = screen.getAllByRole('button', { name: 'Download' });
- expect(downloadButtons.length).toBeGreaterThanOrEqual(1);
- });
-
- it('reads the backend via memoryTreeGetLlm on mount and persists toggles via memoryTreeSetLlm', async () => {
- renderWithProviders();
-
- // Bootstrap: getMemoryTreeLlm must run once on mount.
- await waitFor(() => {
- expect(memoryTreeGetLlm).toHaveBeenCalled();
- });
-
- // Click Local — setMemoryTreeLlm must be called with the request
- // object form `{ backend: 'local' }`. settingsApi.ts always normalizes
- // to the request-object shape because the wrapper now accepts both
- // forms but the API layer translates camelCase options through the
- // object shape. Model fields are absent so the corresponding
- // config keys stay untouched.
- const radios = screen.getAllByRole('radio');
- const localCard = radios.find(el => /Advanced/.test(el.textContent ?? ''));
- fireEvent.click(localCard!);
-
- await waitFor(() => {
- expect(memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'local' });
- });
-
- // The mocked setter persists state in the closure, so the bootstrap
- // value of any subsequent get_llm call would now be 'local' — sanity
- // check that the closure flipped.
- const after = await memoryTreeGetLlm();
- expect(after.current).toBe('local');
- });
-
- it('persists Memory LLM dropdown changes via memoryTreeSetLlm with both extract_model and summariser_model', async () => {
- // The single Memory LLM picker fans out to BOTH extract_model and
- // summariser_model in one atomic write — the underlying schema keeps
- // the two keys separate so power users can split via the RPC, but the
- // UI consolidates them into one cognitive unit.
- renderWithProviders();
- await flipToLocal();
-
- // Reset call history so the assertion below is scoped to the
- // dropdown change, not the earlier backend toggle.
- memoryTreeSetLlm.mockClear();
-
- // Pick a different memory LLM. `gemma3:12b-it-qat` is in the curated
- // catalog with both `extract` and `summariser` roles.
- const memorySelect = screen.getByLabelText(
- 'Memory LLM (extract + summarise)'
- ) as HTMLSelectElement;
- fireEvent.change(memorySelect, { target: { value: 'gemma3:12b-it-qat' } });
-
- await waitFor(() => {
- expect(memoryTreeSetLlm).toHaveBeenCalledWith({
- backend: 'local',
- extract_model: 'gemma3:12b-it-qat',
- summariser_model: 'gemma3:12b-it-qat',
- });
- });
- });
-});
diff --git a/app/src/components/intelligence/__tests__/ModelCatalog.test.tsx b/app/src/components/intelligence/__tests__/ModelCatalog.test.tsx
deleted file mode 100644
index 8f357a2e0..000000000
--- a/app/src/components/intelligence/__tests__/ModelCatalog.test.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-
-import ModelCatalog from '../ModelCatalog';
-
-describe('ModelCatalog', () => {
- it('renders one row per recommended model', () => {
- render(
-
- );
- // Each id from RECOMMENDED_MODEL_CATALOG appears as a row title.
- expect(screen.getByText('qwen2.5:0.5b')).toBeInTheDocument();
- expect(screen.getByText('gemma3:1b-it-qat')).toBeInTheDocument();
- expect(screen.getByText('gemma3:4b')).toBeInTheDocument();
- expect(screen.getByText('gemma3:12b-it-qat')).toBeInTheDocument();
- expect(screen.getByText('bge-m3')).toBeInTheDocument();
- });
-
- it('shows "Download" for models that are not installed', () => {
- render(
-
- );
- // Five models, all available → five Download buttons.
- expect(screen.getAllByRole('button', { name: /download/i })).toHaveLength(5);
- expect(screen.getAllByText('not downloaded').length).toBeGreaterThan(0);
- });
-
- it('shows "Use" for installed-but-not-active models, "in use" for active', () => {
- render(
-
- );
- // bge-m3 is installed AND active → "in use" pill, no Use button for it.
- expect(screen.getAllByText('in use').length).toBeGreaterThan(0);
- // gemma3 is installed but not active → Use button visible.
- expect(screen.getByRole('button', { name: 'Use' })).toBeInTheDocument();
- });
-
- it('matches `bge-m3` against `bge-m3:latest` via the :latest normalization', () => {
- // Ollama tags everything as `:latest` by default; the catalog uses bare
- // names. The component must treat them as the same id.
- render(
-
- );
- // bge-m3 row is now in the "installed" state — at least one Use button
- // appears (for bge-m3 specifically).
- expect(screen.getByRole('button', { name: 'Use' })).toBeInTheDocument();
- });
-
- it('fires onUse with the matching model when the Use button is clicked', () => {
- const onUse = vi.fn();
- render(
-
- );
- fireEvent.click(screen.getByRole('button', { name: 'Use' }));
- expect(onUse).toHaveBeenCalledTimes(1);
- expect(onUse.mock.calls[0][0]).toMatchObject({ id: 'gemma3:4b' });
- });
-
- it('renders Delete buttons only when onDelete is provided', () => {
- const { rerender } = render(
-
- );
- expect(screen.queryByLabelText('Delete model')).toBeNull();
-
- rerender(
-
- );
- expect(screen.getByLabelText('Delete model')).toBeInTheDocument();
- });
-
- it('shows a progress bar while a download is in flight, then clears it', async () => {
- let resolveDownload!: () => void;
- const onDownload = vi.fn(
- () =>
- new Promise(resolve => {
- resolveDownload = resolve;
- })
- );
- render(
-
- );
-
- fireEvent.click(screen.getAllByRole('button', { name: /download/i })[0]);
-
- // Mid-flight: a progressbar is rendered for that row.
- await waitFor(() => {
- expect(screen.queryAllByRole('progressbar').length).toBeGreaterThan(0);
- });
-
- resolveDownload();
- // After settle (~600 ms on success), the bar disappears and the row
- // returns to its post-install state. We just confirm the state
- // eventually clears — not the exact timing.
- await waitFor(
- () => {
- expect(screen.queryByRole('progressbar')).toBeNull();
- },
- { timeout: 2000 }
- );
- });
-});
diff --git a/app/src/components/settings/panels/LocalModelPanel.tsx b/app/src/components/settings/panels/LocalModelPanel.tsx
index 5077f273e..00d772487 100644
--- a/app/src/components/settings/panels/LocalModelPanel.tsx
+++ b/app/src/components/settings/panels/LocalModelPanel.tsx
@@ -9,8 +9,11 @@ import {
} from '../../../utils/localAiHelpers';
import {
type ApplyPresetResult,
+ type LlmBackend,
type LocalAiDownloadsProgress,
type LocalAiStatus,
+ memoryTreeGetLlm,
+ memoryTreeSetLlm,
openhumanGetConfig,
openhumanLocalAiApplyPreset,
openhumanLocalAiDownload,
@@ -59,6 +62,16 @@ const LocalModelPanel = () => {
const [usageError, setUsageError] = useState('');
const [usageSaving, setUsageSaving] = useState(false);
+ // Memory summarizer backend lives in `memory_tree.llm_backend`, which is
+ // outside the `local_ai.usage.*` surface — so it has its own RPC pair
+ // (`memoryTreeGetLlm` / `memoryTreeSetLlm`). This is now the only UI
+ // surface for the cloud/local toggle (the Intelligence Memory tab's
+ // BackendChooser was removed in this PR to eliminate the duplicate
+ // control surface). The tab's local-only Ollama model picker reads
+ // the same field at mount to decide visibility.
+ const [summarizerBackend, setSummarizerBackend] = useState('cloud');
+ const [summarizerSaving, setSummarizerSaving] = useState(false);
+
const progress = useMemo(() => {
const downloadProgress = progressFromDownloads(downloads);
if (downloadProgress != null) return downloadProgress;
@@ -143,11 +156,48 @@ const LocalModelPanel = () => {
}
};
+ const loadSummarizerBackend = async () => {
+ try {
+ const resp = await memoryTreeGetLlm();
+ if (resp?.current === 'local' || resp?.current === 'cloud') {
+ setSummarizerBackend(resp.current);
+ }
+ } catch (err) {
+ // Non-fatal — the row stays at its default (cloud) and the user
+ // can still flip it; the next save attempt will surface the error.
+ console.warn('[local-model-panel] memoryTreeGetLlm failed', err);
+ }
+ };
+
+ const updateSummarizerBackend = async (next: LlmBackend) => {
+ // Belt-and-braces: the checkbox is already `disabled` when the
+ // master runtime is off or a save is in flight, but the rendered
+ // disabled attribute only stops real-browser click events — JSDOM
+ // / fireEvent ignore it. Mirror the gate here so programmatic
+ // dispatch can't sneak past the master switch either.
+ if (!usageFlags.runtime_enabled || summarizerSaving) return;
+ const prev = summarizerBackend;
+ setSummarizerBackend(next);
+ setSummarizerSaving(true);
+ setUsageError('');
+ try {
+ await memoryTreeSetLlm({ backend: next });
+ } catch (err) {
+ // Roll back the optimistic toggle and surface the error.
+ setSummarizerBackend(prev);
+ const msg = err instanceof Error ? err.message : 'Failed to save memory summarizer backend';
+ setUsageError(msg);
+ } finally {
+ setSummarizerSaving(false);
+ }
+ };
+
useEffect(() => {
const initialLoad = window.setTimeout(() => {
void loadStatus();
void loadPresets();
void loadUsage();
+ void loadSummarizerBackend();
}, 0);
const timer = window.setInterval(() => {
void loadStatus();
@@ -366,6 +416,30 @@ const LocalModelPanel = () => {
+ {/* Memory summarizer is special: it writes `memory_tree.llm_backend`,
+ not `local_ai.usage.*`. This is now the sole UI surface for
+ that field (the Intelligence Memory tab's BackendChooser was
+ removed); the tab's Ollama model picker still reads it at
+ mount to gate visibility. */}
+
+
{(
[
{
diff --git a/app/src/components/settings/panels/__tests__/LocalModelPanel.test.tsx b/app/src/components/settings/panels/__tests__/LocalModelPanel.test.tsx
index 23ee6522a..83f0d70cb 100644
--- a/app/src/components/settings/panels/__tests__/LocalModelPanel.test.tsx
+++ b/app/src/components/settings/panels/__tests__/LocalModelPanel.test.tsx
@@ -8,6 +8,8 @@ import {
isTauri,
type LocalAiDownloadsProgress,
type LocalAiStatus,
+ memoryTreeGetLlm,
+ memoryTreeSetLlm,
openhumanGetConfig,
openhumanLocalAiDownload,
openhumanLocalAiDownloadAllAssets,
@@ -21,6 +23,8 @@ import LocalModelPanel from '../LocalModelPanel';
vi.mock('../../../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
+ memoryTreeGetLlm: vi.fn(),
+ memoryTreeSetLlm: vi.fn(),
openhumanGetConfig: vi.fn(),
openhumanLocalAiDownload: vi.fn(),
openhumanLocalAiDownloadAllAssets: vi.fn(),
@@ -124,6 +128,11 @@ describe('LocalModelPanel — usage flags', () => {
};
return makeSnapshot(runtime);
});
+
+ // Memory summarizer backend defaults to cloud; tests that need a
+ // specific seed value override this in the test body.
+ vi.mocked(memoryTreeGetLlm).mockResolvedValue({ current: 'cloud' });
+ vi.mocked(memoryTreeSetLlm).mockResolvedValue({ current: 'local' });
});
it('renders all five usage toggles with sub-flags disabled when runtime is off', async () => {
@@ -215,4 +224,86 @@ describe('LocalModelPanel — usage flags', () => {
expect(openhumanUpdateLocalAiSettings).toHaveBeenCalledWith({ usage_embeddings: true });
});
});
+
+ // The Memory summarizer checkbox is special — it writes
+ // `memory_tree.llm_backend` via memoryTreeSetLlm (the same field the
+ // removed Intelligence → Memory BackendChooser used to edit), not
+ // `local_ai.usage.*`. State seeds from memoryTreeGetLlm on mount.
+ it('seeds the Memory summarizer checkbox state from memoryTreeGetLlm', async () => {
+ vi.mocked(memoryTreeGetLlm).mockResolvedValueOnce({ current: 'local' });
+ runtime.runtime_enabled = true;
+ renderWithProviders(, { initialEntries: ['/settings/local-model'] });
+
+ const summarizerLabel = await screen.findByText('Memory summarizer');
+ const checkbox = summarizerLabel
+ .closest('label')
+ ?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+ expect(checkbox).toBeTruthy();
+ await waitFor(() => {
+ expect(memoryTreeGetLlm).toHaveBeenCalled();
+ });
+ await waitFor(() => {
+ expect(checkbox.checked).toBe(true);
+ });
+ });
+
+ it('flips the Memory summarizer checkbox and persists via memoryTreeSetLlm', async () => {
+ runtime.runtime_enabled = true;
+ renderWithProviders(, { initialEntries: ['/settings/local-model'] });
+
+ const summarizerLabel = await screen.findByText('Memory summarizer');
+ const checkbox = summarizerLabel
+ .closest('label')
+ ?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+ // The checkbox starts disabled until the async loadUsage() flips
+ // `usageFlags.runtime_enabled` to true; wait for that before clicking.
+ await waitFor(() => {
+ expect(checkbox).not.toBeDisabled();
+ });
+ fireEvent.click(checkbox);
+
+ await waitFor(() => {
+ expect(memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'local' });
+ });
+ });
+
+ it('rolls back the Memory summarizer optimistic toggle when memoryTreeSetLlm fails', async () => {
+ runtime.runtime_enabled = true;
+ vi.mocked(memoryTreeSetLlm).mockRejectedValueOnce(new Error('save: backend down'));
+ renderWithProviders(, { initialEntries: ['/settings/local-model'] });
+
+ const summarizerLabel = await screen.findByText('Memory summarizer');
+ const checkbox = summarizerLabel
+ .closest('label')
+ ?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+ await waitFor(() => {
+ expect(checkbox).not.toBeDisabled();
+ });
+ fireEvent.click(checkbox);
+
+ // The error message surfaces in the shared usageError block.
+ await screen.findByText('save: backend down');
+ // And the checkbox rolls back to its prior state (cloud → unchecked).
+ await waitFor(() => {
+ expect(checkbox.checked).toBe(false);
+ });
+ });
+
+ it('does not call memoryTreeSetLlm when the Memory summarizer is disabled (runtime off)', async () => {
+ // runtime is OFF by default — summarizer checkbox should be disabled
+ // and clicks should not fire a setLlm call.
+ renderWithProviders(, { initialEntries: ['/settings/local-model'] });
+
+ const summarizerLabel = await screen.findByText('Memory summarizer');
+ const checkbox = summarizerLabel
+ .closest('label')
+ ?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+ expect(checkbox).toBeDisabled();
+ // Exercise the disabled-click path — fireEvent dispatches even on
+ // disabled inputs (it bypasses React's synthetic event guard), so
+ // this confirms the handler doesn't fire `setLlm` because of the
+ // gating, not just because no click happened.
+ fireEvent.click(checkbox);
+ expect(memoryTreeSetLlm).not.toHaveBeenCalled();
+ });
});
diff --git a/app/src/lib/intelligence/__tests__/settingsApi.test.ts b/app/src/lib/intelligence/__tests__/settingsApi.test.ts
deleted file mode 100644
index ab3f3104f..000000000
--- a/app/src/lib/intelligence/__tests__/settingsApi.test.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import {
- capabilityForModel,
- downloadAsset,
- fetchInstalledAssets,
- fetchInstalledModels,
- fetchLocalAiStatus,
- fetchPresets,
- formatBytes,
- getMemoryTreeLlm,
- type ModelDescriptor,
- setMemoryTreeLlm,
-} from '../settingsApi';
-
-// Stub the underlying tauri-command wrappers; we're testing the
-// camelCase→snake_case translation + simple try/catch shells, not the
-// RPC plumbing.
-vi.mock('../../../utils/tauriCommands', () => ({
- isTauri: vi.fn(() => true),
- memoryTreeGetLlm: vi.fn(),
- memoryTreeSetLlm: vi.fn(),
- openhumanLocalAiAssetsStatus: vi.fn(),
- openhumanLocalAiStatus: vi.fn(),
- openhumanLocalAiDiagnostics: vi.fn(),
- openhumanLocalAiPresets: vi.fn(),
- openhumanLocalAiDownloadAsset: vi.fn(),
-}));
-
-const tauri = (await import('../../../utils/tauriCommands')) as unknown as {
- memoryTreeGetLlm: ReturnType;
- memoryTreeSetLlm: ReturnType;
- openhumanLocalAiAssetsStatus: ReturnType;
- openhumanLocalAiStatus: ReturnType;
- openhumanLocalAiDiagnostics: ReturnType;
- openhumanLocalAiPresets: ReturnType;
- openhumanLocalAiDownloadAsset: ReturnType;
-};
-
-beforeEach(() => {
- Object.values(tauri).forEach(fn => fn.mockReset());
-});
-
-describe('getMemoryTreeLlm', () => {
- it('returns the current backend value from the RPC', async () => {
- tauri.memoryTreeGetLlm.mockResolvedValueOnce({ current: 'cloud' });
- await expect(getMemoryTreeLlm()).resolves.toBe('cloud');
- expect(tauri.memoryTreeGetLlm).toHaveBeenCalledTimes(1);
- });
-});
-
-describe('setMemoryTreeLlm', () => {
- it('passes only the backend when no options are supplied', async () => {
- tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'cloud' });
- await setMemoryTreeLlm('cloud');
- expect(tauri.memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'cloud' });
- });
-
- it('translates camelCase options to snake_case wire fields and passes only those that are set', async () => {
- tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'local' });
- await setMemoryTreeLlm('local', { extractModel: 'a:b', summariserModel: 'c:d' });
- expect(tauri.memoryTreeSetLlm).toHaveBeenCalledWith({
- backend: 'local',
- extract_model: 'a:b',
- summariser_model: 'c:d',
- });
- // cloudModel was unset → cloud_model must NOT be on the wire payload.
- expect(tauri.memoryTreeSetLlm.mock.calls[0][0]).not.toHaveProperty('cloud_model');
- });
-
- it('returns the effective backend value the core decided on', async () => {
- tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'cloud' });
- const out = await setMemoryTreeLlm('local');
- expect(out).toEqual({ effective: 'cloud' });
- });
-});
-
-describe('fetchInstalledAssets', () => {
- it('unwraps the `result` field on success', async () => {
- tauri.openhumanLocalAiAssetsStatus.mockResolvedValueOnce({ result: { foo: 1 } });
- await expect(fetchInstalledAssets()).resolves.toEqual({ foo: 1 });
- });
- it('swallows RPC errors and returns null', async () => {
- tauri.openhumanLocalAiAssetsStatus.mockRejectedValueOnce(new Error('boom'));
- await expect(fetchInstalledAssets()).resolves.toBeNull();
- });
-});
-
-describe('fetchLocalAiStatus', () => {
- it('returns null on RPC failure', async () => {
- tauri.openhumanLocalAiStatus.mockRejectedValueOnce(new Error('nope'));
- await expect(fetchLocalAiStatus()).resolves.toBeNull();
- });
-});
-
-describe('fetchInstalledModels', () => {
- it('returns the installed_models array', async () => {
- tauri.openhumanLocalAiDiagnostics.mockResolvedValueOnce({
- installed_models: [{ name: 'bge-m3', size_bytes: 1 }],
- });
- const got = await fetchInstalledModels();
- expect(got).toHaveLength(1);
- expect(got[0]?.name).toBe('bge-m3');
- });
- it('returns [] when the RPC rejects', async () => {
- tauri.openhumanLocalAiDiagnostics.mockRejectedValueOnce(new Error('rpc down'));
- await expect(fetchInstalledModels()).resolves.toEqual([]);
- });
- it('returns [] when installed_models is missing', async () => {
- tauri.openhumanLocalAiDiagnostics.mockResolvedValueOnce({});
- await expect(fetchInstalledModels()).resolves.toEqual([]);
- });
-});
-
-describe('fetchPresets', () => {
- it('forwards the response on success', async () => {
- tauri.openhumanLocalAiPresets.mockResolvedValueOnce({ presets: [] });
- await expect(fetchPresets()).resolves.toEqual({ presets: [] });
- });
- it('returns null on failure', async () => {
- tauri.openhumanLocalAiPresets.mockRejectedValueOnce(new Error('x'));
- await expect(fetchPresets()).resolves.toBeNull();
- });
-});
-
-describe('downloadAsset', () => {
- it('returns the result envelope on success', async () => {
- tauri.openhumanLocalAiDownloadAsset.mockResolvedValueOnce({ result: { ok: true } });
- await expect(downloadAsset('chat')).resolves.toEqual({ ok: true });
- expect(tauri.openhumanLocalAiDownloadAsset).toHaveBeenCalledWith('chat');
- });
- it('returns null on failure (and does not throw)', async () => {
- tauri.openhumanLocalAiDownloadAsset.mockRejectedValueOnce(new Error('disconnected'));
- await expect(downloadAsset('embedding')).resolves.toBeNull();
- });
-});
-
-describe('capabilityForModel', () => {
- const make = (roles: ModelDescriptor['roles']): ModelDescriptor => ({
- id: 'x',
- size: '0',
- approxBytes: 0,
- ramHint: '0',
- category: 'fast',
- note: '',
- roles,
- });
- it('maps embedder → embedding', () => {
- expect(capabilityForModel(make(['embedder']))).toBe('embedding');
- });
- it('maps extract / summariser → chat', () => {
- expect(capabilityForModel(make(['extract']))).toBe('chat');
- expect(capabilityForModel(make(['summariser']))).toBe('chat');
- expect(capabilityForModel(make(['extract', 'summariser']))).toBe('chat');
- });
- it('returns null when no role binds to a known capability', () => {
- expect(capabilityForModel(make([]))).toBeNull();
- });
-});
-
-describe('formatBytes', () => {
- it('falls back to em-dash for non-finite or zero', () => {
- expect(formatBytes(Number.NaN)).toBe('—');
- expect(formatBytes(0)).toBe('—');
- expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('—');
- });
- it('formats GB for >= 1 GiB', () => {
- expect(formatBytes(2.5 * 1024 ** 3)).toBe('2.5 GB');
- });
- it('formats MB (rounded) for sub-GB inputs', () => {
- expect(formatBytes(150 * 1024 ** 2)).toBe('150 MB');
- expect(formatBytes(1024 ** 2 + 1)).toBe('1 MB');
- });
-});
diff --git a/app/src/lib/intelligence/settingsApi.ts b/app/src/lib/intelligence/settingsApi.ts
deleted file mode 100644
index bcff84a5e..000000000
--- a/app/src/lib/intelligence/settingsApi.ts
+++ /dev/null
@@ -1,269 +0,0 @@
-/**
- * Settings tab API layer for the Intelligence page.
- *
- * Wraps the existing `local_ai_*` core RPCs (re-exported with cleaner names)
- * and the canonical `openhuman.memory_tree_get_llm` / `set_llm` JSON-RPC
- * methods that drive the AI-backend selector. Both come from the shared
- * `utils/tauriCommands` barrel.
- *
- * Logging convention: `[intelligence-settings-api]` prefix for grep-friendly
- * tracing of the new flow per the project debug-logging rule.
- */
-import {
- type LlmBackend,
- type LocalAiAssetsStatus,
- type LocalAiDiagnostics,
- type LocalAiStatus,
- memoryTreeGetLlm,
- memoryTreeSetLlm,
- openhumanLocalAiAssetsStatus,
- openhumanLocalAiDiagnostics,
- openhumanLocalAiDownloadAsset,
- openhumanLocalAiPresets,
- openhumanLocalAiStatus,
- type PresetsResponse,
-} from '../../utils/tauriCommands';
-
-/**
- * AI backend the assistant is currently using for chat. Re-exports the
- * canonical `LlmBackend` from the wrapper so both names remain valid as
- * call-sites migrate.
- */
-export type Backend = LlmBackend;
-
-/** Static descriptor used by ModelAssignment + ModelCatalog. */
-export interface ModelDescriptor {
- /** Ollama-style identifier (e.g. `qwen2.5:0.5b`). */
- id: string;
- /** Pretty label shown in the UI (defaults to `id` when omitted). */
- label?: string;
- /** Human-readable disk size, e.g. `400 MB`. */
- size: string;
- /** Bytes — approximate; surfaced for sort / filter. */
- approxBytes: number;
- /** Approx RAM hint, e.g. `≤4 GB RAM`. */
- ramHint: string;
- /** Speed / quality tier — used for the inline annotation under each row. */
- category: 'fast' | 'balanced' | 'high quality' | 'embedder';
- /** One-sentence note about when to pick this model. */
- note: string;
- /** Role(s) this model is suitable for. */
- roles: ReadonlyArray<'extract' | 'summariser' | 'embedder'>;
-}
-
-export type ModelRole = 'extract' | 'summariser' | 'embedder';
-
-/**
- * Hard-coded recommended catalog. In a future wave this should come from
- * a `local_ai.recommended_catalog` RPC; for v1 we ship a curated list so
- * the UI is fully populated without a server roundtrip.
- */
-export const RECOMMENDED_MODEL_CATALOG: ReadonlyArray = [
- {
- id: 'qwen2.5:0.5b',
- size: '400 MB',
- approxBytes: 400 * 1024 * 1024,
- ramHint: '≤4 GB RAM',
- category: 'fast',
- note: 'compact, lower quality',
- roles: ['extract'],
- },
- {
- id: 'gemma3:1b-it-qat',
- size: '1.0 GB',
- approxBytes: Math.round(1.0 * 1024 * 1024 * 1024),
- ramHint: '≤4 GB RAM',
- category: 'fast',
- note: 'compact Gemma; OK on laptops without a GPU',
- roles: ['extract', 'summariser'],
- },
- {
- id: 'gemma3:4b',
- size: '3.3 GB',
- approxBytes: Math.round(3.3 * 1024 * 1024 * 1024),
- ramHint: '≤8 GB RAM',
- category: 'balanced',
- note: 'default summariser — coherent abstractive output',
- roles: ['extract', 'summariser'],
- },
- {
- id: 'gemma3:12b-it-qat',
- size: '8.9 GB',
- approxBytes: Math.round(8.9 * 1024 * 1024 * 1024),
- ramHint: '≥16 GB RAM',
- category: 'high quality',
- note: 'larger Gemma; sharper summaries on capable hardware',
- roles: ['extract', 'summariser'],
- },
- {
- id: 'bge-m3',
- size: '1.3 GB',
- approxBytes: Math.round(1.3 * 1024 * 1024 * 1024),
- ramHint: '≥4 GB RAM',
- category: 'embedder',
- note: 'required for embeddings',
- roles: ['embedder'],
- },
-];
-
-export const DEFAULT_EXTRACT_MODEL = 'gemma3:4b';
-export const DEFAULT_SUMMARISER_MODEL = 'gemma3:4b';
-export const REQUIRED_EMBEDDER_MODEL = 'bge-m3';
-
-/**
- * Reads the currently configured chat backend from the core.
- *
- * Backed by `openhuman.memory_tree_get_llm` — the value persists across
- * sidecar restarts via `config.toml`.
- */
-export async function getMemoryTreeLlm(): Promise {
- console.debug('[intelligence-settings-api] getMemoryTreeLlm: entry');
- const resp = await memoryTreeGetLlm();
- console.debug('[intelligence-settings-api] getMemoryTreeLlm: exit current=%s', resp.current);
- return resp.current;
-}
-
-/**
- * Optional per-role model picks for {@link setMemoryTreeLlm}. Field names
- * are camelCase here to match TS conventions; the wrapper translates them
- * to the snake_case wire shape the Rust `SetLlmRequest` expects:
- *
- * | TS option | Rust / wire field | Targets `memory_tree.*` |
- * | ----------------- | ------------------- | ----------------------- |
- * | `cloudModel` | `cloud_model` | `cloud_llm_model` |
- * | `extractModel` | `extract_model` | `llm_extractor_model` |
- * | `summariserModel` | `summariser_model` | `llm_summariser_model` |
- *
- * Each field follows "absent → unchanged, present → overwritten" so a
- * caller flipping just the backend doesn't have to re-supply every model
- * id, and a caller persisting just one role doesn't have to re-supply
- * the others.
- */
-export interface SetMemoryTreeLlmOptions {
- cloudModel?: string;
- extractModel?: string;
- summariserModel?: string;
-}
-
-/**
- * Switches the chat backend and (optionally) persists per-role model
- * choices in the same atomic `config.toml` write. Returns the effective
- * value the core agreed on — today the handler accepts the input
- * verbatim, but a future revision may downgrade `local` → `cloud` when
- * the host can't satisfy the local minimums.
- *
- * Backed by `openhuman.memory_tree_set_llm`.
- *
- * Existing one-arg callers — `setMemoryTreeLlm('cloud')` — keep working
- * unchanged because `options` is optional.
- */
-export async function setMemoryTreeLlm(
- next: Backend,
- options?: SetMemoryTreeLlmOptions
-): Promise<{ effective: Backend }> {
- console.debug(
- '[intelligence-settings-api] setMemoryTreeLlm: entry next=%s cloudModel=%s extractModel=%s summariserModel=%s',
- next,
- options?.cloudModel ?? '',
- options?.extractModel ?? '',
- options?.summariserModel ?? ''
- );
- // camelCase → snake_case translation lives here, in one place. The
- // wrapper layer just forwards the snake_case shape to the wire.
- const resp = await memoryTreeSetLlm({
- backend: next,
- ...(options?.cloudModel !== undefined && { cloud_model: options.cloudModel }),
- ...(options?.extractModel !== undefined && { extract_model: options.extractModel }),
- ...(options?.summariserModel !== undefined && { summariser_model: options.summariserModel }),
- });
- console.debug('[intelligence-settings-api] setMemoryTreeLlm: exit effective=%s', resp.current);
- return { effective: resp.current };
-}
-
-/** Re-export the existing assets status fetch with a friendlier name. */
-export async function fetchInstalledAssets(): Promise {
- try {
- const response = await openhumanLocalAiAssetsStatus();
- return response.result;
- } catch (err) {
- console.debug('[intelligence-settings-api] fetchInstalledAssets failed', err);
- return null;
- }
-}
-
-/**
- * Fetch local AI status (includes per-capability state + last latency).
- * Used by `CurrentlyLoaded` to render Ollama-side telemetry.
- */
-export async function fetchLocalAiStatus(): Promise {
- try {
- const response = await openhumanLocalAiStatus();
- return response.result;
- } catch (err) {
- console.debug('[intelligence-settings-api] fetchLocalAiStatus failed', err);
- return null;
- }
-}
-
-/**
- * Reach into the existing diagnostics RPC for the list of installed Ollama
- * models. The diagnostics endpoint already enumerates them and is the
- * cleanest single source of truth — we do not duplicate the model table.
- */
-export async function fetchInstalledModels(): Promise {
- try {
- const response = await openhumanLocalAiDiagnostics();
- return response.installed_models ?? [];
- } catch (err) {
- console.debug('[intelligence-settings-api] fetchInstalledModels failed', err);
- return [];
- }
-}
-
-export async function fetchPresets(): Promise {
- try {
- return await openhumanLocalAiPresets();
- } catch (err) {
- console.debug('[intelligence-settings-api] fetchPresets failed', err);
- return null;
- }
-}
-
-/**
- * Trigger a download for a capability (chat / vision / embedding / stt / tts).
- * Used by ModelCatalog when the user clicks "Download".
- *
- * NOTE: the real RPC is per-capability, not per-model-id, so the catalog
- * picks the closest matching capability. This is acceptable for v1; future
- * iterations can swap in a per-model RPC.
- */
-export async function downloadAsset(
- capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
-): Promise {
- try {
- const response = await openhumanLocalAiDownloadAsset(capability);
- return response.result;
- } catch (err) {
- console.debug('[intelligence-settings-api] downloadAsset failed', { capability, err });
- return null;
- }
-}
-
-/** Map a model descriptor to the closest capability bucket the core exposes. */
-export function capabilityForModel(model: ModelDescriptor): 'chat' | 'embedding' | null {
- if (model.roles.includes('embedder')) return 'embedding';
- if (model.roles.includes('extract') || model.roles.includes('summariser')) return 'chat';
- return null;
-}
-
-/**
- * Cheap pretty-printer for a byte count. Mirrors the `JetBrains Mono`-style
- * compact format we want in the technical-readout sections.
- */
-export function formatBytes(bytes: number): string {
- if (!Number.isFinite(bytes) || bytes <= 0) return '—';
- const gb = bytes / (1024 * 1024 * 1024);
- if (gb >= 1) return `${gb.toFixed(1)} GB`;
- const mb = bytes / (1024 * 1024);
- return `${Math.round(mb)} MB`;
-}
diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx
index 867c53576..4d15ba3b7 100644
--- a/app/src/pages/Intelligence.tsx
+++ b/app/src/pages/Intelligence.tsx
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from 'react';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import IntelligenceCallsTab from '../components/intelligence/IntelligenceCallsTab';
import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab';
-import IntelligenceSettingsTab from '../components/intelligence/IntelligenceSettingsTab';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
import { ToastContainer } from '../components/intelligence/Toast';
@@ -21,7 +20,7 @@ import type {
ToastNotification,
} from '../types/intelligence';
-type IntelligenceTab = 'memory' | 'subconscious' | 'calls' | 'dreams' | 'settings';
+type IntelligenceTab = 'memory' | 'subconscious' | 'calls' | 'dreams';
export default function Intelligence() {
const { aiStatus } = useIntelligenceStats();
@@ -133,7 +132,6 @@ export default function Intelligence() {
{ id: 'subconscious', label: 'Subconscious' },
{ id: 'calls', label: 'Calls' },
{ id: 'dreams', label: 'Dreams', comingSoon: true },
- { id: 'settings', label: 'Settings' },
];
return (
@@ -244,8 +242,6 @@ export default function Intelligence() {
{activeTab === 'calls' && }
{activeTab === 'dreams' && }
-
- {activeTab === 'settings' && }
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 21e5be8bb..4f514b0dd 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -10,6 +10,12 @@ import { nodePolyfills } from "vite-plugin-node-polyfills";
const host = process.env.TAURI_DEV_HOST;
+// Optional override so parallel `dev:app:win` runs across worktrees can
+// avoid the hardcoded 1420 collision. Default 1420 preserves prior behavior;
+// HMR companion port is dev port + 1 (used only when TAURI_DEV_HOST is set).
+const devPort = Number(process.env.OPENHUMAN_DEV_PORT) || 1420;
+const hmrPort = devPort + 1;
+
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
readFileSync(resolve(__dirname, "package.json"), "utf8"),
@@ -138,7 +144,7 @@ export default defineConfig(async () => ({
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
- port: 1420,
+ port: devPort,
strictPort: true,
// `false` lets Vite pick its own loopback default; on Windows that lands
// on `::1` only, leaving 127.0.0.1 unbound. The Tauri dev-server proxy
@@ -161,7 +167,7 @@ export default defineConfig(async () => ({
? {
protocol: "ws",
host,
- port: 1421,
+ port: hmrPort,
}
: {
// Tauri CEF loads the app from tauri.localhost; without this the
@@ -169,8 +175,8 @@ export default defineConfig(async () => ({
// Force the client to connect to the Vite dev server directly.
protocol: "ws",
host: "localhost",
- port: 1420,
- clientPort: 1420,
+ port: devPort,
+ clientPort: devPort,
},
watch: {
// 3. tell Vite to ignore watching `src-tauri` directory (includes src-tauri/ai)
diff --git a/scripts/run-dev-win.sh b/scripts/run-dev-win.sh
index 384040923..8492a24da 100644
--- a/scripts/run-dev-win.sh
+++ b/scripts/run-dev-win.sh
@@ -199,7 +199,7 @@ if [[ -z "${WindowsSdkDir:-}" || "${WindowsSDKVersion:-}" == "\\" || -z "${Windo
if [[ -d "$sdk_root_unix/Lib" ]]; then
sdk_version="$(ls -d "$sdk_root_unix"/Lib/*/ 2>/dev/null \
| sort -V | tail -n1 \
- | sed 's|.*/||; s|/||g')"
+ | sed 's|/$||; s|.*/||')"
if [[ -n "$sdk_version" && -f "$sdk_root_unix/Lib/$sdk_version/um/x64/kernel32.lib" ]]; then
sdk_root_win="$(cygpath -w "$sdk_root_unix")"
export WindowsSdkDir="${sdk_root_win}\\"
@@ -527,8 +527,19 @@ export PATH="$PATH_PREFIX:$PATH"
# destination, so subsequent dev runs are essentially free.
# ─────────────────────────────────────────────────────────────────────────────
if [[ -n "${CEF_RUNTIME_PATH:-}" && -f "$CEF_RUNTIME_PATH/libcef.dll" ]]; then
- CARGO_TARGET_DIR_UNIX="$(to_unix_path "${CARGO_TARGET_DIR:-$REPO_ROOT/target}" 2>/dev/null || printf '%s' "${CARGO_TARGET_DIR:-$REPO_ROOT/target}")"
- CEF_STAGE_DIR="$CARGO_TARGET_DIR_UNIX/debug"
+ # The dev OpenHuman.exe is produced by the *Tauri shell* crate
+ # (app/src-tauri/Cargo.toml), not the root core crate. When
+ # CARGO_TARGET_DIR is set both workspaces share it; when unset, the
+ # Tauri shell builds into app/src-tauri/target while the root crate
+ # builds into target/. Stage CEF next to where OpenHuman.exe will
+ # actually live so Windows' DLL search order finds libcef.dll
+ # regardless of how the exe is launched (terminal, OAuth deep-link,
+ # double-click, etc).
+ if [[ -n "${CARGO_TARGET_DIR:-}" ]]; then
+ CEF_STAGE_DIR="$(to_unix_path "$CARGO_TARGET_DIR" 2>/dev/null || printf '%s' "$CARGO_TARGET_DIR")/debug"
+ else
+ CEF_STAGE_DIR="$REPO_ROOT/app/src-tauri/target/debug"
+ fi
mkdir -p "$CEF_STAGE_DIR"
if [[ ! -f "$CEF_STAGE_DIR/libcef.dll" \
|| "$CEF_RUNTIME_PATH/libcef.dll" -nt "$CEF_STAGE_DIR/libcef.dll" ]]; then
@@ -546,4 +557,28 @@ fi
# Use the vendored tauri-cef CLI (via the pnpm tauri script) so the
# CEF runtime is correctly bundled. APPLE_SIGNING_IDENTITY is macOS-only
# and is intentionally omitted here.
-"$PNPM_EXE" tauri dev
+#
+# OPENHUMAN_DEV_PORT lets parallel worktree dev sessions avoid the
+# hardcoded 1420 collision. Vite reads the same env var directly; the
+# tauri-cli inline override patches tauri.conf.json's `devUrl` so the
+# shell connects to the right Vite instance.
+# Validate OPENHUMAN_DEV_PORT before interpolating into JSON — a stray
+# space, alphabetic char, or out-of-range value would produce an invalid
+# devUrl and tauri would refuse to start (or worse, drift from Vite's
+# own numeric fallback). Trim whitespace, require pure digits in
+# [1, 65535], fall back to 1420 with a warning otherwise.
+raw_dev_port="${OPENHUMAN_DEV_PORT:-1420}"
+raw_dev_port="${raw_dev_port//[[:space:]]/}"
+if [[ "$raw_dev_port" =~ ^[0-9]+$ ]] && (( raw_dev_port >= 1 && raw_dev_port <= 65535 )); then
+ DEV_PORT="$raw_dev_port"
+else
+ echo "[run-dev-win] WARNING: invalid OPENHUMAN_DEV_PORT='$raw_dev_port'; falling back to 1420" >&2
+ DEV_PORT=1420
+fi
+
+if (( DEV_PORT != 1420 )); then
+ echo "[run-dev-win] OPENHUMAN_DEV_PORT=$DEV_PORT — overriding tauri devUrl"
+ "$PNPM_EXE" tauri dev -c "{\"build\":{\"devUrl\":\"http://localhost:$DEV_PORT\"}}"
+else
+ "$PNPM_EXE" tauri dev
+fi
diff --git a/src/openhuman/config/schema/local_ai.rs b/src/openhuman/config/schema/local_ai.rs
index 3e7490842..5af23324e 100644
--- a/src/openhuman/config/schema/local_ai.rs
+++ b/src/openhuman/config/schema/local_ai.rs
@@ -131,7 +131,11 @@ fn default_vision_model_id() -> String {
}
fn default_embedding_model_id() -> String {
- "all-minilm:latest".to_string()
+ // bge-m3 (1024 dims, 8192-token context). Required by the memory tree's
+ // fixed on-disk embedding format (EMBEDDING_DIM=1024) — `all-minilm`
+ // (384 dims) and `nomic-embed-text` (768 dims) would fail the
+ // post-call dim validator at `memory::tree::score::embed::mod::embed`.
+ "bge-m3".to_string()
}
fn default_stt_model_id() -> String {
diff --git a/src/openhuman/embeddings/ollama.rs b/src/openhuman/embeddings/ollama.rs
index df828e2c1..dac82e5ca 100644
--- a/src/openhuman/embeddings/ollama.rs
+++ b/src/openhuman/embeddings/ollama.rs
@@ -4,7 +4,10 @@
//! This is the preferred local provider: Ollama handles model management,
//! quantization, and GPU acceleration (Metal on macOS, CUDA on Linux/Windows).
//!
-//! Default model: `nomic-embed-text:latest` (768 dimensions).
+//! Default model: `bge-m3` (1024 dimensions). Aligned with the memory
+//! tree's fixed on-disk format (`EMBEDDING_DIM=1024`) and the cloud
+//! Voyage default (`embedding-v1`, 1024 dims) so embeddings produced by
+//! either path are interchangeable.
use async_trait::async_trait;
@@ -13,11 +16,12 @@ use super::EmbeddingProvider;
/// Default Ollama base URL.
pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
-/// Default embedding model for Ollama.
-pub const DEFAULT_OLLAMA_MODEL: &str = "nomic-embed-text:latest";
+/// Default embedding model for Ollama. 1024-dim to match the memory
+/// tree's fixed on-disk format and the cloud Voyage default.
+pub const DEFAULT_OLLAMA_MODEL: &str = "bge-m3";
-/// Default dimensions for nomic-embed-text.
-pub const DEFAULT_OLLAMA_DIMENSIONS: usize = 768;
+/// Default dimensions for `bge-m3`.
+pub const DEFAULT_OLLAMA_DIMENSIONS: usize = 1024;
/// Embedding provider backed by a local Ollama instance.
///
@@ -35,8 +39,8 @@ impl OllamaEmbedding {
/// Creates a new Ollama embedding provider.
///
/// - `base_url`: Ollama server URL (default: `http://localhost:11434`)
- /// - `model`: Model name (default: `nomic-embed-text:latest`)
- /// - `dims`: Expected embedding dimensions (default: 768)
+ /// - `model`: Model name (default: `bge-m3`)
+ /// - `dims`: Expected embedding dimensions (default: 1024)
pub fn try_new(base_url: &str, model: &str, dims: usize) -> anyhow::Result {
let base_url = Self::normalize_base_url(base_url)?;
let model = Self::normalize_model(model)?;
diff --git a/src/openhuman/local_ai/model_ids.rs b/src/openhuman/local_ai/model_ids.rs
index 01b50feb9..99b1f858c 100644
--- a/src/openhuman/local_ai/model_ids.rs
+++ b/src/openhuman/local_ai/model_ids.rs
@@ -10,7 +10,7 @@ use crate::openhuman::config::Config;
pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "gemma3:1b-it-qat";
pub(crate) const DEFAULT_OLLAMA_VISION_MODEL: &str = "";
pub(crate) const DEFAULT_LOW_VISION_MODEL: &str = "moondream:1.8b-v2-q4_K_S";
-pub(crate) const DEFAULT_OLLAMA_EMBED_MODEL: &str = "all-minilm:latest";
+pub(crate) const DEFAULT_OLLAMA_EMBED_MODEL: &str = "bge-m3";
/// Chat models allowed in the current MVP build (2–4 GB tier only).
/// Any resolved chat model ID not listed here is redirected to `MVP_DEFAULT_CHAT_MODEL`.
@@ -22,7 +22,11 @@ const MVP_DEFAULT_CHAT_MODEL: &str = "gemma3:1b-it-qat";
const MVP_ALLOWED_VISION_MODELS: &[&str] = &[""];
/// Embedding models allowed in MVP (2–4 GB tier uses all-minilm).
-const MVP_ALLOWED_EMBEDDING_MODELS: &[&str] = &["all-minilm:latest"];
+// bge-m3 (1024-dim, 8192-token context) is the canonical local embedder
+// for memory tree's fixed on-disk format. all-minilm (384-dim) is kept
+// for back-compat with users who pulled it under an older default, but
+// new selections should default to bge-m3.
+const MVP_ALLOWED_EMBEDDING_MODELS: &[&str] = &["bge-m3", "all-minilm:latest"];
fn enforce_mvp_chat_allowlist(resolved: &str) -> String {
let lower = resolved.to_ascii_lowercase();
@@ -195,6 +199,47 @@ mod tests {
assert_eq!(effective_vision_model_id(&config), "");
}
+ #[test]
+ fn embedding_model_empty_falls_back_to_bge_m3() {
+ // After the cloud-embeddings unification PR, the default embedder
+ // for the local Ollama path is bge-m3 (1024 dim) to match memory
+ // tree's fixed on-disk format. Empty / whitespace input must
+ // resolve to that default, not the prior all-minilm:latest.
+ let mut config = test_config();
+ config.local_ai.embedding_model_id = String::new();
+ assert_eq!(effective_embedding_model_id(&config), "bge-m3");
+
+ config.local_ai.embedding_model_id = " ".to_string();
+ assert_eq!(effective_embedding_model_id(&config), "bge-m3");
+ }
+
+ #[test]
+ fn embedding_model_passes_through_allowlisted_legacy() {
+ // all-minilm:latest is kept in MVP_ALLOWED_EMBEDDING_MODELS for
+ // back-compat with users who already pulled it under the prior
+ // default. It is NOT 1024-dim — memory tree's post-call validator
+ // will surface that mismatch at embed time — but the allowlist
+ // enforcer itself must let the value pass through unchanged.
+ let mut config = test_config();
+ config.local_ai.embedding_model_id = "all-minilm:latest".to_string();
+ assert_eq!(effective_embedding_model_id(&config), "all-minilm:latest");
+ }
+
+ #[test]
+ fn embedding_model_rejects_non_allowlisted_and_redirects_to_default() {
+ // Any non-allowlisted value (including legacy nomic-embed-text:latest
+ // and arbitrary user input) is silently redirected to the canonical
+ // default. This is the path that fired the "embedding model not in
+ // MVP allowlist, redirecting to default" warning on every embed
+ // resolution before bge-m3 was added to the allowlist.
+ let mut config = test_config();
+ config.local_ai.embedding_model_id = "nomic-embed-text:latest".to_string();
+ assert_eq!(effective_embedding_model_id(&config), "bge-m3");
+
+ config.local_ai.embedding_model_id = "totally-made-up-model:v0".to_string();
+ assert_eq!(effective_embedding_model_id(&config), "bge-m3");
+ }
+
#[test]
fn stt_tts_and_quantization_defaults_are_applied() {
let mut config = test_config();
diff --git a/src/openhuman/local_ai/presets.rs b/src/openhuman/local_ai/presets.rs
index 092b86715..9e03d6de1 100644
--- a/src/openhuman/local_ai/presets.rs
+++ b/src/openhuman/local_ai/presets.rs
@@ -128,13 +128,15 @@ pub fn all_presets() -> Vec {
"Battery-friendly local summarization preset. Uses the 1B Gemma model with vision disabled.",
chat_model_id: "gemma3:1b-it-qat",
vision_model_id: "",
- embedding_model_id: "all-minilm:latest",
+ // bge-m3 — 1024 dims required by memory tree's on-disk format
+ // and 8192-token context for long-chunk embeds.
+ embedding_model_id: "bge-m3",
quantization: "qat",
vision_mode: VisionMode::Disabled,
supports_screen_summary: false,
target_ram_gb: 2,
min_ram_gb: 2,
- approx_download_gb: 1.1,
+ approx_download_gb: 2.3,
},
ModelPreset {
tier: ModelTier::Ram4To8Gb,
diff --git a/src/openhuman/memory/tree/score/embed/cloud.rs b/src/openhuman/memory/tree/score/embed/cloud.rs
new file mode 100644
index 000000000..682c1eeed
--- /dev/null
+++ b/src/openhuman/memory/tree/score/embed/cloud.rs
@@ -0,0 +1,101 @@
+//! Cloud (Voyage-backed) embedder for the memory tree.
+//!
+//! Adapts the OpenHuman backend's `POST /openai/v1/embeddings` surface
+//! (Voyage `voyage-3.5`, 1024 dims) to the memory_tree [`Embedder`] trait
+//! so Phase 4 ingest / bucket-seal can vectorize chunks without a local
+//! Ollama install.
+//!
+//! The 1024-dim output matches existing on-disk blobs (which were
+//! produced by `bge-m3`, also 1024-dim), so this is a drop-in replacement
+//! for the Ollama path — no migration of `mem_tree_chunks.embedding`
+//! required.
+//!
+//! Auth: the cloud embedder resolves the session JWT per call via
+//! [`OpenHumanCloudEmbedding`], so a session refresh between batches is
+//! picked up transparently. When the user is unauthenticated the first
+//! `embed()` returns an error; ingest treats that the same as any other
+//! embedder failure (don't persist the row, let job retry).
+
+use anyhow::{Context, Result};
+use async_trait::async_trait;
+
+use super::{Embedder, EMBEDDING_DIM};
+use crate::openhuman::config::Config;
+use crate::openhuman::embeddings::cloud::{
+ OpenHumanCloudEmbedding, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL,
+};
+use crate::openhuman::embeddings::EmbeddingProvider;
+
+/// Cloud-backed memory_tree embedder.
+///
+/// Wraps [`OpenHumanCloudEmbedding`] (which speaks the OpenAI-compatible
+/// `/openai/v1/embeddings` shape backed by Voyage on the OpenHuman
+/// backend) and adapts it to the memory_tree [`Embedder`] trait.
+pub struct CloudEmbedder {
+ inner: OpenHumanCloudEmbedding,
+}
+
+impl CloudEmbedder {
+ /// Build a cloud embedder using the same backend resolution as the
+ /// main embeddings path: `api_url` falls back to
+ /// [`effective_api_url`](crate::api::config::effective_api_url) and
+ /// the workspace dir comes from `config.workspace_dir` so the auth
+ /// service finds the user's session JWT.
+ pub fn new(config: &Config) -> Self {
+ let openhuman_dir = config.config_path.parent().map(std::path::PathBuf::from);
+ Self {
+ inner: OpenHumanCloudEmbedding::new(
+ None,
+ openhuman_dir,
+ config.secrets.encrypt,
+ DEFAULT_CLOUD_EMBEDDING_MODEL,
+ DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
+ ),
+ }
+ }
+}
+
+#[async_trait]
+impl Embedder for CloudEmbedder {
+ fn name(&self) -> &'static str {
+ "cloud"
+ }
+
+ async fn embed(&self, text: &str) -> Result> {
+ let v = self
+ .inner
+ .embed_one(text)
+ .await
+ .context("cloud embeddings failed")?;
+ if v.len() != EMBEDDING_DIM {
+ anyhow::bail!(
+ "cloud embedder returned {} dims, expected {}",
+ v.len(),
+ EMBEDDING_DIM
+ );
+ }
+ Ok(v)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::openhuman::config::Config;
+ use tempfile::TempDir;
+
+ fn test_config() -> (TempDir, Config) {
+ let tmp = TempDir::new().unwrap();
+ let mut cfg = Config::default();
+ cfg.workspace_dir = tmp.path().to_path_buf();
+ cfg.config_path = tmp.path().join("config.toml");
+ (tmp, cfg)
+ }
+
+ #[test]
+ fn name_is_cloud() {
+ let (_tmp, cfg) = test_config();
+ let e = CloudEmbedder::new(&cfg);
+ assert_eq!(e.name(), "cloud");
+ }
+}
diff --git a/src/openhuman/memory/tree/score/embed/factory.rs b/src/openhuman/memory/tree/score/embed/factory.rs
index 5b8843265..6990b4492 100644
--- a/src/openhuman/memory/tree/score/embed/factory.rs
+++ b/src/openhuman/memory/tree/score/embed/factory.rs
@@ -1,12 +1,28 @@
-//! Build an [`Embedder`] from [`Config::memory_tree`] settings.
+//! Build an [`Embedder`] from [`Config`] settings.
//!
//! Resolution order:
-//! 1. `memory_tree.embedding_endpoint` + `memory_tree.embedding_model`
-//! both Some → [`OllamaEmbedder`]
-//! 2. Otherwise → depends on `memory_tree.embedding_strict`:
-//! - `true` → bail with a clear "configure Ollama for Phase 4" error
-//! - `false` → fall back to [`InertEmbedder`] (zero vectors) with a
-//! warn log so the operator notices embeddings are disabled
+//! 1. **Explicit override** — `memory_tree.embedding_endpoint` +
+//! `memory_tree.embedding_model` both Some → [`OllamaEmbedder`] with
+//! those exact values. For power users / E2E test rigs that want to
+//! point at a non-default Ollama endpoint.
+//! 2. **Local-AI usage flag** — `config.local_ai.use_local_for_embeddings()`
+//! (i.e. `runtime_enabled && usage.embeddings`) → [`OllamaEmbedder`]
+//! against [`ollama_base_url`] with the user's chosen
+//! `config.local_ai.embedding_model_id`. This is the path driven by
+//! the "Memory embeddings" checkbox in Local AI Settings.
+//! 3. **Default** — [`CloudEmbedder`] (OpenHuman backend / Voyage,
+//! 1024 dims). Auth failures surface at the first `embed()` call so
+//! ingest's existing retry-with-backoff logic handles them.
+//!
+//! NOTE on dimensions: the memory tree on-disk format is hard-coded at
+//! [`EMBEDDING_DIM`](super::EMBEDDING_DIM) (1024). If the user picks a
+//! local embedding model whose output is a different dimensionality,
+//! the trait's post-call validator rejects each embed with a clear
+//! `expected N dims, got M` error. Switching the local model picker in
+//! Local AI Settings is the fix.
+//!
+//! The historical `InertEmbedder` (zero vectors) path is retained for
+//! tests only — it is no longer the production lax-mode fallback.
//!
//! Env var overrides applied in [`crate::openhuman::config::load`]:
//! - `OPENHUMAN_MEMORY_EMBED_ENDPOINT`
@@ -15,8 +31,24 @@
use anyhow::Result;
-use super::{Embedder, InertEmbedder, OllamaEmbedder};
+use super::{CloudEmbedder, Embedder, InertEmbedder, OllamaEmbedder};
use crate::openhuman::config::Config;
+use crate::openhuman::local_ai::ollama_base_url;
+
+/// Cheap heuristic for "is a backend session reachable?" — the cloud
+/// embedder needs one and bails on first embed call without it. We use
+/// the *presence* of `auth-profiles.json` next to the config file as a
+/// proxy: production after login has it, test harnesses and fresh
+/// pre-login installs don't. The CloudEmbedder still re-validates the
+/// JWT at every embed call, so a stale file just surfaces at embed
+/// time (not factory build), preserving the prior failure behavior.
+fn cloud_session_available(config: &Config) -> bool {
+ config
+ .config_path
+ .parent()
+ .map(|dir| dir.join("auth-profiles.json").exists())
+ .unwrap_or(false)
+}
/// Construct the active embedder for this process, honouring
/// `config.memory_tree.*` and `embedding_strict`.
@@ -48,23 +80,45 @@ pub fn build_embedder_from_config(config: &Config) -> Result>
)))
}
_ => {
- if tree_cfg.embedding_strict {
- anyhow::bail!(
- "memory_tree embedding is required (embedding_strict=true) but \
- embedding_endpoint/embedding_model are unset. Set \
- `memory_tree.embedding_endpoint` + `.embedding_model` in \
- config.toml or export OPENHUMAN_MEMORY_EMBED_ENDPOINT / \
- OPENHUMAN_MEMORY_EMBED_MODEL — or set \
- `memory_tree.embedding_strict = false` to fall back to zero \
- vectors (embeddings will not contribute to retrieval rerank)."
+ // Honor the Local AI Settings "Memory embeddings" checkbox.
+ // `use_local_for_embeddings()` is `runtime_enabled && usage.embeddings`
+ // so we never route to a disabled local runtime.
+ if config.local_ai.use_local_for_embeddings() {
+ let model = config.local_ai.embedding_model_id.clone();
+ let endpoint = ollama_base_url();
+ let timeout_ms = tree_cfg.embedding_timeout_ms.unwrap_or(0);
+ log::debug!(
+ "[memory_tree::embed::factory] usage.embeddings=true — using local Ollama endpoint={} model={} timeout_ms={}",
+ endpoint, model, timeout_ms
);
+ Ok(Box::new(OllamaEmbedder::new(endpoint, model, timeout_ms)))
+ } else if cloud_session_available(config) {
+ // Default for logged-in users: cloud (OpenHuman backend /
+ // Voyage `voyage-3.5`, 1024 dims). Matches the main
+ // embeddings path so a fresh install needs zero local
+ // Ollama setup. JWT failures (expired, invalid, etc.)
+ // surface as embed-call errors so ingest's existing
+ // retry-with-backoff logic handles them.
+ log::debug!(
+ "[memory_tree::embed::factory] using cloud (Voyage) — \
+ flip 'Memory embeddings' in Local AI Settings to switch to local"
+ );
+ Ok(Box::new(CloudEmbedder::new(config)))
+ } else {
+ // Pre-login, test harness, or unauthenticated runtime
+ // path — no auth-profiles.json on disk means the cloud
+ // path has no chance of resolving a bearer. Drop to
+ // InertEmbedder (zero vectors) so ingest/seal/retrieval
+ // can run without panic; semantic rerank degrades to
+ // recency only until the user logs in (or until they
+ // flip "Memory embeddings" to local with Ollama running).
+ log::warn!(
+ "[memory_tree::embed::factory] no backend session found — \
+ using InertEmbedder (zero vectors). Log in to OpenHuman, or \
+ enable 'Memory embeddings' in Local AI Settings, to fix."
+ );
+ Ok(Box::new(InertEmbedder::new()))
}
- log::warn!(
- "[memory_tree::embed::factory] no embedding endpoint/model — \
- falling back to InertEmbedder (zero vectors). Set \
- memory_tree.embedding_endpoint to enable semantic retrieval."
- );
- Ok(Box::new(InertEmbedder::new()))
}
}
}
@@ -78,9 +132,25 @@ mod tests {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
+ // Plant config_path in the tempdir so cloud_session_available()
+ // checks a writable directory; tests that need to simulate a
+ // logged-in user just `touch` auth-profiles.json next to it.
+ cfg.config_path = tmp.path().join("config.toml");
(tmp, cfg)
}
+ /// Drop a stub `auth-profiles.json` next to the test config so
+ /// `cloud_session_available()` returns true. Contents don't matter
+ /// — the factory only checks presence.
+ fn touch_auth_profile(cfg: &Config) {
+ let path = cfg
+ .config_path
+ .parent()
+ .map(|p| p.join("auth-profiles.json"))
+ .expect("config_path has a parent");
+ std::fs::write(&path, "{}").expect("write stub auth-profiles.json");
+ }
+
#[test]
fn ollama_chosen_when_endpoint_and_model_set() {
let (_tmp, mut cfg) = test_config();
@@ -92,36 +162,94 @@ mod tests {
}
#[test]
- fn strict_mode_bails_on_missing_endpoint() {
- let (_tmp, mut cfg) = test_config();
- cfg.memory_tree.embedding_endpoint = None;
- cfg.memory_tree.embedding_model = None;
- cfg.memory_tree.embedding_strict = true;
- // `Box` isn't `Debug`, so go through `match` rather
- // than `unwrap_err` (which needs Debug on the Ok variant).
- match build_embedder_from_config(&cfg) {
- Ok(_) => panic!("expected strict-mode bail"),
- Err(e) => assert!(e.to_string().contains("embedding_strict"), "{e}"),
- }
- }
-
- #[test]
- fn lax_mode_falls_back_to_inert() {
+ fn unset_endpoint_with_session_routes_to_cloud() {
let (_tmp, mut cfg) = test_config();
cfg.memory_tree.embedding_endpoint = None;
cfg.memory_tree.embedding_model = None;
cfg.memory_tree.embedding_strict = false;
- let e = build_embedder_from_config(&cfg).expect("lax path should build");
+ touch_auth_profile(&cfg);
+ let e = build_embedder_from_config(&cfg).expect("cloud default should build");
+ assert_eq!(e.name(), "cloud");
+ }
+
+ #[test]
+ fn unset_endpoint_without_session_falls_back_to_inert() {
+ // Test harness / pre-login: no auth-profiles.json on disk,
+ // factory degrades to InertEmbedder so callers don't crash on
+ // first embed call.
+ let (_tmp, mut cfg) = test_config();
+ cfg.memory_tree.embedding_endpoint = None;
+ cfg.memory_tree.embedding_model = None;
+ cfg.memory_tree.embedding_strict = false;
+ let e = build_embedder_from_config(&cfg).expect("inert fallback should build");
assert_eq!(e.name(), "inert");
}
#[test]
- fn empty_strings_count_as_unset() {
+ fn empty_strings_count_as_unset_with_session() {
let (_tmp, mut cfg) = test_config();
cfg.memory_tree.embedding_endpoint = Some("".into());
cfg.memory_tree.embedding_model = Some("".into());
cfg.memory_tree.embedding_strict = false;
- let e = build_embedder_from_config(&cfg).expect("lax path should build");
- assert_eq!(e.name(), "inert");
+ touch_auth_profile(&cfg);
+ let e = build_embedder_from_config(&cfg).expect("cloud default should build");
+ assert_eq!(e.name(), "cloud");
+ }
+
+ #[test]
+ fn strict_mode_no_longer_bails_with_cloud_default() {
+ // Strict mode used to bail when endpoint/model were unset because
+ // the only fallback was InertEmbedder. Now the lax-and-strict
+ // paths share the cloud fallback; strict bail is a no-op here
+ // and auth failures surface at first embed() call instead.
+ let (_tmp, mut cfg) = test_config();
+ cfg.memory_tree.embedding_endpoint = None;
+ cfg.memory_tree.embedding_model = None;
+ cfg.memory_tree.embedding_strict = true;
+ touch_auth_profile(&cfg);
+ let e = build_embedder_from_config(&cfg).expect("cloud default should build");
+ assert_eq!(e.name(), "cloud");
+ }
+
+ #[test]
+ fn local_ai_usage_embeddings_routes_to_ollama() {
+ // When the Local AI Settings "Memory embeddings" checkbox is on
+ // (runtime_enabled && usage.embeddings), memory tree routes to
+ // Ollama using the user's chosen `embedding_model_id`. The
+ // explicit endpoint/model override is left unset so we exercise
+ // the use_local_for_embeddings() branch.
+ let (_tmp, mut cfg) = test_config();
+ cfg.memory_tree.embedding_endpoint = None;
+ cfg.memory_tree.embedding_model = None;
+ cfg.local_ai.runtime_enabled = true;
+ cfg.local_ai.usage.embeddings = true;
+ cfg.local_ai.embedding_model_id = "all-minilm:latest".to_string();
+ let e = build_embedder_from_config(&cfg).expect("ollama path should build");
+ assert_eq!(e.name(), "ollama");
+ }
+
+ #[test]
+ fn local_ai_usage_off_with_session_falls_back_to_cloud() {
+ // runtime_enabled=true but usage.embeddings=false → cloud (with session).
+ let (_tmp, mut cfg) = test_config();
+ cfg.memory_tree.embedding_endpoint = None;
+ cfg.memory_tree.embedding_model = None;
+ cfg.local_ai.runtime_enabled = true;
+ cfg.local_ai.usage.embeddings = false;
+ touch_auth_profile(&cfg);
+ let e = build_embedder_from_config(&cfg).expect("cloud default should build");
+ assert_eq!(e.name(), "cloud");
+ }
+
+ #[test]
+ fn explicit_endpoint_override_wins_over_local_ai_flag() {
+ // Power-user override beats the checkbox.
+ let (_tmp, mut cfg) = test_config();
+ cfg.memory_tree.embedding_endpoint = Some("http://staging-embed:11434".into());
+ cfg.memory_tree.embedding_model = Some("bge-m3".into());
+ cfg.local_ai.runtime_enabled = true;
+ cfg.local_ai.usage.embeddings = true;
+ let e = build_embedder_from_config(&cfg).expect("override path should build");
+ assert_eq!(e.name(), "ollama");
}
}
diff --git a/src/openhuman/memory/tree/score/embed/mod.rs b/src/openhuman/memory/tree/score/embed/mod.rs
index 6ebdced59..3f9048be0 100644
--- a/src/openhuman/memory/tree/score/embed/mod.rs
+++ b/src/openhuman/memory/tree/score/embed/mod.rs
@@ -29,10 +29,12 @@
use anyhow::{Context, Result};
use async_trait::async_trait;
+pub mod cloud;
pub mod factory;
pub mod inert;
pub mod ollama;
+pub use cloud::CloudEmbedder;
pub use factory::build_embedder_from_config;
pub use inert::InertEmbedder;
pub use ollama::OllamaEmbedder;