diff --git a/CHANGELOG.md b/CHANGELOG.md index 673883e848d..51a4b1069df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs. - Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (`MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `moonshot/kimi-k2.5`, `zai/glm-5`) and keep Moonshot model lists synced from shared source data. +- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff. - Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat. - Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli. - Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index c8b2db0b091..b3940945249 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -109,6 +109,8 @@ Defaults: 6. Otherwise memory search stays disabled until configured. - Local mode uses node-llama-cpp and may require `pnpm approve-builds`. - Uses sqlite-vec (when available) to accelerate vector search inside SQLite. +- `memorySearch.provider = "ollama"` is also supported for local/self-hosted + Ollama embeddings (`/api/embeddings`), but it is not auto-selected. Remote embeddings **require** an API key for the embedding provider. OpenClaw resolves keys from auth profiles, `models.providers.*.apiKey`, or environment @@ -116,7 +118,9 @@ variables. Codex OAuth only covers chat/completions and does **not** satisfy embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or `models.providers.google.apiKey`. For Voyage, use `VOYAGE_API_KEY` or `models.providers.voyage.apiKey`. For Mistral, use `MISTRAL_API_KEY` or -`models.providers.mistral.apiKey`. +`models.providers.mistral.apiKey`. Ollama typically does not require a real API +key (a placeholder like `OLLAMA_API_KEY=ollama-local` is enough when needed by +local policy). When using a custom OpenAI-compatible endpoint, set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`). @@ -331,7 +335,7 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se Fallbacks: -- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `local`, or `none`. +- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`. - The fallback provider is only used when the primary embedding provider fails. Batch indexing (OpenAI + Gemini + Voyage): diff --git a/docs/help/faq.md b/docs/help/faq.md index 36cd63892ea..d7737bc31a5 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1299,12 +1299,13 @@ It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key resolves, then Voyage, then Mistral. If no remote key is available, memory search stays disabled until you configure it. If you have a local model path configured and present, OpenClaw -prefers `local`. +prefers `local`. Ollama is supported when you explicitly set +`memorySearch.provider = "ollama"`. If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally `memorySearch.fallback = "none"`). If you want Gemini embeddings, set `memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or -`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, or local** embedding +`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, Ollama, or local** embedding models - see [Memory](/concepts/memory) for the setup details. ### Does memory persist forever What are the limits diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index 58fec7538fa..a1002fc88ad 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -68,6 +68,7 @@ Semantic memory search uses **embedding APIs** when configured for remote provid - `memorySearch.provider = "gemini"` → Gemini embeddings - `memorySearch.provider = "voyage"` → Voyage embeddings - `memorySearch.provider = "mistral"` → Mistral embeddings +- `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing) - Optional fallback to a remote provider if local embeddings fail You can keep it local with `memorySearch.provider = "local"` (no API usage). diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index a49aefa4634..5fe1120cf58 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -6,7 +6,7 @@ const asConfig = (cfg: OpenClawConfig): OpenClawConfig => cfg; describe("memory search config", () => { function configWithDefaultProvider( - provider: "openai" | "local" | "gemini" | "mistral", + provider: "openai" | "local" | "gemini" | "mistral" | "ollama", ): OpenClawConfig { return asConfig({ agents: { @@ -156,6 +156,13 @@ describe("memory search config", () => { expect(resolved?.model).toBe("mistral-embed"); }); + it("includes remote defaults and model default for ollama without overrides", () => { + const cfg = configWithDefaultProvider("ollama"); + const resolved = resolveMemorySearchConfig(cfg, "main"); + expectDefaultRemoteBatch(resolved); + expect(resolved?.model).toBe("nomic-embed-text"); + }); + it("defaults session delta thresholds", () => { const cfg = asConfig({ agents: { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index a8aadc15b2c..7b4e40b1df6 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -9,7 +9,7 @@ export type ResolvedMemorySearchConfig = { enabled: boolean; sources: Array<"memory" | "sessions">; extraPaths: string[]; - provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "auto"; + provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto"; remote?: { baseUrl?: string; apiKey?: string; @@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = { experimental: { sessionMemory: boolean; }; - fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "none"; + fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none"; model: string; local: { modelPath?: string; @@ -82,6 +82,7 @@ const DEFAULT_OPENAI_MODEL = "text-embedding-3-small"; const DEFAULT_GEMINI_MODEL = "gemini-embedding-001"; const DEFAULT_VOYAGE_MODEL = "voyage-4-large"; const DEFAULT_MISTRAL_MODEL = "mistral-embed"; +const DEFAULT_OLLAMA_MODEL = "nomic-embed-text"; const DEFAULT_CHUNK_TOKENS = 400; const DEFAULT_CHUNK_OVERLAP = 80; const DEFAULT_WATCH_DEBOUNCE_MS = 1500; @@ -155,6 +156,7 @@ function mergeConfig( provider === "gemini" || provider === "voyage" || provider === "mistral" || + provider === "ollama" || provider === "auto"; const batch = { enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? false, @@ -186,7 +188,9 @@ function mergeConfig( ? DEFAULT_VOYAGE_MODEL : provider === "mistral" ? DEFAULT_MISTRAL_MODEL - : undefined; + : provider === "ollama" + ? DEFAULT_OLLAMA_MODEL + : undefined; const model = overrides?.model ?? defaults?.model ?? modelDefault ?? ""; const local = { modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath, diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 33400074649..26877ca92b2 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -235,6 +235,31 @@ describe("noteMemorySearchHealth", () => { const message = String(note.mock.calls[0]?.[0] ?? ""); expect(message).toContain("openclaw configure --section model"); }); + + it("still warns in auto mode when only ollama credentials exist", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + }); + resolveApiKeyForProvider.mockImplementation(async ({ provider }: { provider: string }) => { + if (provider === "ollama") { + return { + apiKey: "ollama-local", + source: "env: OLLAMA_API_KEY", + mode: "api-key", + }; + } + throw new Error("missing key"); + }); + + await noteMemorySearchHealth(cfg); + + expect(note).toHaveBeenCalledTimes(1); + const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>; + const providersChecked = providerCalls.map(([arg]) => arg.provider); + expect(providersChecked).toEqual(["openai", "google", "voyage", "mistral"]); + }); }); describe("detectLegacyWorkspaceDirs", () => { diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 22515e79c99..eda33823ec8 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -186,7 +186,7 @@ function hasLocalEmbeddings(local: { modelPath?: string }, useDefaultFallback = } async function hasApiKeyForProvider( - provider: "openai" | "gemini" | "voyage" | "mistral", + provider: "openai" | "gemini" | "voyage" | "mistral" | "ollama", cfg: OpenClawConfig, agentDir: string, ): Promise { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 3e4d558f349..f4f0023f7fd 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -724,7 +724,7 @@ export const FIELD_HELP: Record = { "agents.defaults.memorySearch.experimental.sessionMemory": "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", "agents.defaults.memorySearch.provider": - 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", or "local". Keep your most reliable provider here and configure fallback for resilience.', + 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', "agents.defaults.memorySearch.model": "Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.", "agents.defaults.memorySearch.remote.baseUrl": @@ -746,7 +746,7 @@ export const FIELD_HELP: Record = { "agents.defaults.memorySearch.local.modelPath": "Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.", "agents.defaults.memorySearch.fallback": - 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', + 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', "agents.defaults.memorySearch.store.path": "Sets where the SQLite memory index is stored on disk for each agent. Keep the default `~/.openclaw/memory/{agentId}.sqlite` unless you need custom storage placement or backup policy alignment.", "agents.defaults.memorySearch.store.vector.enabled": diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 9d45a9d7fa4..67d65c1ba0e 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -324,7 +324,7 @@ export type MemorySearchConfig = { sessionMemory?: boolean; }; /** Embedding provider mode. */ - provider?: "openai" | "gemini" | "local" | "voyage" | "mistral"; + provider?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama"; remote?: { baseUrl?: string; apiKey?: string; @@ -343,7 +343,7 @@ export type MemorySearchConfig = { }; }; /** Fallback behavior when embeddings fail. */ - fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "none"; + fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none"; /** Embedding model id (remote) or alias (local). */ model?: string; /** Local embedding settings (node-llama-cpp). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 3f1ddb61e71..d780dfea8f9 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -557,6 +557,7 @@ export const MemorySearchSchema = z z.literal("gemini"), z.literal("voyage"), z.literal("mistral"), + z.literal("ollama"), ]) .optional(), remote: z @@ -584,6 +585,7 @@ export const MemorySearchSchema = z z.literal("local"), z.literal("voyage"), z.literal("mistral"), + z.literal("ollama"), z.literal("none"), ]) .optional(), diff --git a/src/memory/embeddings-ollama.test.ts b/src/memory/embeddings-ollama.test.ts new file mode 100644 index 00000000000..30cb767fb55 --- /dev/null +++ b/src/memory/embeddings-ollama.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createOllamaEmbeddingProvider } from "./embeddings-ollama.js"; + +describe("embeddings-ollama", () => { + it("calls /api/embeddings and returns normalized vectors", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ embedding: [3, 4] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + globalThis.fetch = fetchMock; + + const { provider } = await createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { baseUrl: "http://127.0.0.1:11434" }, + }); + + const v = await provider.embedQuery("hi"); + expect(fetchMock).toHaveBeenCalledTimes(1); + // normalized [3,4] => [0.6,0.8] + expect(v[0]).toBeCloseTo(0.6, 5); + expect(v[1]).toBeCloseTo(0.8, 5); + }); + + it("resolves baseUrl/apiKey/headers from models.providers.ollama and strips /v1", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ embedding: [1, 0] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + globalThis.fetch = fetchMock; + + const { provider } = await createOllamaEmbeddingProvider({ + config: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + apiKey: "ollama-local", + headers: { + "X-Provider-Header": "provider", + }, + }, + }, + }, + } as unknown as OpenClawConfig, + provider: "ollama", + model: "", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434/api/embeddings", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer ollama-local", + "X-Provider-Header": "provider", + }), + }), + ); + }); +}); diff --git a/src/memory/embeddings-ollama.ts b/src/memory/embeddings-ollama.ts new file mode 100644 index 00000000000..50e511aec78 --- /dev/null +++ b/src/memory/embeddings-ollama.ts @@ -0,0 +1,137 @@ +import { resolveEnvApiKey } from "../agents/model-auth.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; +import { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./remote-http.js"; + +export type OllamaEmbeddingClient = { + baseUrl: string; + headers: Record; + ssrfPolicy?: SsrFPolicy; + model: string; + embedBatch: (texts: string[]) => Promise; +}; +type OllamaEmbeddingClientConfig = Omit; + +export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text"; +const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"; + +function sanitizeAndNormalizeEmbedding(vec: number[]): number[] { + const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0)); + const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0)); + if (magnitude < 1e-10) { + return sanitized; + } + return sanitized.map((value) => value / magnitude); +} + +function normalizeOllamaModel(model: string): string { + const trimmed = model.trim(); + if (!trimmed) { + return DEFAULT_OLLAMA_EMBEDDING_MODEL; + } + if (trimmed.startsWith("ollama/")) { + return trimmed.slice("ollama/".length); + } + return trimmed; +} + +function resolveOllamaApiBase(configuredBaseUrl?: string): string { + if (!configuredBaseUrl) { + return DEFAULT_OLLAMA_BASE_URL; + } + const trimmed = configuredBaseUrl.replace(/\/+$/, ""); + return trimmed.replace(/\/v1$/i, ""); +} + +function resolveOllamaApiKey(options: EmbeddingProviderOptions): string | undefined { + const remoteApiKey = options.remote?.apiKey?.trim(); + if (remoteApiKey) { + return remoteApiKey; + } + const providerApiKey = normalizeOptionalSecretInput( + options.config.models?.providers?.ollama?.apiKey, + ); + if (providerApiKey) { + return providerApiKey; + } + return resolveEnvApiKey("ollama")?.apiKey; +} + +function resolveOllamaEmbeddingClient( + options: EmbeddingProviderOptions, +): OllamaEmbeddingClientConfig { + const providerConfig = options.config.models?.providers?.ollama; + const rawBaseUrl = options.remote?.baseUrl?.trim() || providerConfig?.baseUrl?.trim(); + const baseUrl = resolveOllamaApiBase(rawBaseUrl); + const model = normalizeOllamaModel(options.model); + const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers); + const headers: Record = { + "Content-Type": "application/json", + ...headerOverrides, + }; + const apiKey = resolveOllamaApiKey(options); + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + return { + baseUrl, + headers, + ssrfPolicy: buildRemoteBaseUrlPolicy(baseUrl), + model, + }; +} + +export async function createOllamaEmbeddingProvider( + options: EmbeddingProviderOptions, +): Promise<{ provider: EmbeddingProvider; client: OllamaEmbeddingClient }> { + const client = resolveOllamaEmbeddingClient(options); + const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embeddings`; + + const embedOne = async (text: string): Promise => { + const json = await withRemoteHttpResponse({ + url: embedUrl, + ssrfPolicy: client.ssrfPolicy, + init: { + method: "POST", + headers: client.headers, + body: JSON.stringify({ model: client.model, prompt: text }), + }, + onResponse: async (res) => { + if (!res.ok) { + throw new Error(`Ollama embeddings HTTP ${res.status}: ${await res.text()}`); + } + return (await res.json()) as { embedding?: number[] }; + }, + }); + if (!Array.isArray(json.embedding)) { + throw new Error(`Ollama embeddings response missing embedding[]`); + } + return sanitizeAndNormalizeEmbedding(json.embedding); + }; + + const provider: EmbeddingProvider = { + id: "ollama", + model: client.model, + embedQuery: embedOne, + embedBatch: async (texts: string[]) => { + // Ollama /api/embeddings accepts one prompt per request. + return await Promise.all(texts.map(embedOne)); + }, + }; + + return { + provider, + client: { + ...client, + embedBatch: async (texts) => { + try { + return await provider.embedBatch(texts); + } catch (err) { + throw new Error(formatErrorMessage(err), { cause: err }); + } + }, + }, + }; +} diff --git a/src/memory/embeddings.ts b/src/memory/embeddings.ts index cbca95a5d4f..9682c08582a 100644 --- a/src/memory/embeddings.ts +++ b/src/memory/embeddings.ts @@ -8,6 +8,7 @@ import { createMistralEmbeddingProvider, type MistralEmbeddingClient, } from "./embeddings-mistral.js"; +import { createOllamaEmbeddingProvider, type OllamaEmbeddingClient } from "./embeddings-ollama.js"; import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js"; import { createVoyageEmbeddingProvider, type VoyageEmbeddingClient } from "./embeddings-voyage.js"; import { importNodeLlamaCpp } from "./node-llama.js"; @@ -25,6 +26,7 @@ export type { GeminiEmbeddingClient } from "./embeddings-gemini.js"; export type { MistralEmbeddingClient } from "./embeddings-mistral.js"; export type { OpenAiEmbeddingClient } from "./embeddings-openai.js"; export type { VoyageEmbeddingClient } from "./embeddings-voyage.js"; +export type { OllamaEmbeddingClient } from "./embeddings-ollama.js"; export type EmbeddingProvider = { id: string; @@ -34,10 +36,13 @@ export type EmbeddingProvider = { embedBatch: (texts: string[]) => Promise; }; -export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral"; +export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; export type EmbeddingProviderRequest = EmbeddingProviderId | "auto"; export type EmbeddingProviderFallback = EmbeddingProviderId | "none"; +// Remote providers considered for auto-selection when provider === "auto". +// Ollama is intentionally excluded here so that "auto" mode does not +// implicitly assume a local Ollama instance is available. const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage", "mistral"] as const; export type EmbeddingProviderResult = { @@ -50,6 +55,7 @@ export type EmbeddingProviderResult = { gemini?: GeminiEmbeddingClient; voyage?: VoyageEmbeddingClient; mistral?: MistralEmbeddingClient; + ollama?: OllamaEmbeddingClient; }; export type EmbeddingProviderOptions = { @@ -152,6 +158,10 @@ export async function createEmbeddingProvider( const provider = await createLocalEmbeddingProvider(options); return { provider }; } + if (id === "ollama") { + const { provider, client } = await createOllamaEmbeddingProvider(options); + return { provider, ollama: client }; + } if (id === "gemini") { const { provider, client } = await createGeminiEmbeddingProvider(options); return { provider, gemini: client }; diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index ac7852eaa35..bfc86afffe7 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -13,6 +13,7 @@ import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { resolveUserPath } from "../utils.js"; import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js"; import { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "./embeddings-mistral.js"; +import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js"; import { DEFAULT_OPENAI_EMBEDDING_MODEL } from "./embeddings-openai.js"; import { DEFAULT_VOYAGE_EMBEDDING_MODEL } from "./embeddings-voyage.js"; import { @@ -20,6 +21,7 @@ import { type EmbeddingProvider, type GeminiEmbeddingClient, type MistralEmbeddingClient, + type OllamaEmbeddingClient, type OpenAiEmbeddingClient, type VoyageEmbeddingClient, } from "./embeddings.js"; @@ -91,11 +93,12 @@ export abstract class MemoryManagerSyncOps { protected abstract readonly workspaceDir: string; protected abstract readonly settings: ResolvedMemorySearchConfig; protected provider: EmbeddingProvider | null = null; - protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral"; + protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; protected openAi?: OpenAiEmbeddingClient; protected gemini?: GeminiEmbeddingClient; protected voyage?: VoyageEmbeddingClient; protected mistral?: MistralEmbeddingClient; + protected ollama?: OllamaEmbeddingClient; protected abstract batch: { enabled: boolean; wait: boolean; @@ -350,7 +353,10 @@ export abstract class MemoryManagerSyncOps { this.fts.available = result.ftsAvailable; if (result.ftsError) { this.fts.loadError = result.ftsError; - log.warn(`fts unavailable: ${result.ftsError}`); + // Only warn when hybrid search is enabled; otherwise this is expected noise. + if (this.fts.enabled) { + log.warn(`fts unavailable: ${result.ftsError}`); + } } } @@ -958,7 +964,13 @@ export abstract class MemoryManagerSyncOps { if (this.fallbackFrom) { return false; } - const fallbackFrom = this.provider.id as "openai" | "gemini" | "local" | "voyage" | "mistral"; + const fallbackFrom = this.provider.id as + | "openai" + | "gemini" + | "local" + | "voyage" + | "mistral" + | "ollama"; const fallbackModel = fallback === "gemini" @@ -969,7 +981,9 @@ export abstract class MemoryManagerSyncOps { ? DEFAULT_VOYAGE_EMBEDDING_MODEL : fallback === "mistral" ? DEFAULT_MISTRAL_EMBEDDING_MODEL - : this.settings.model; + : fallback === "ollama" + ? DEFAULT_OLLAMA_EMBEDDING_MODEL + : this.settings.model; const fallbackResult = await createEmbeddingProvider({ config: this.cfg, @@ -988,6 +1002,7 @@ export abstract class MemoryManagerSyncOps { this.gemini = fallbackResult.gemini; this.voyage = fallbackResult.voyage; this.mistral = fallbackResult.mistral; + this.ollama = fallbackResult.ollama; this.providerKey = this.computeProviderKey(); this.batch = this.resolveBatchConfig(); log.warn(`memory embeddings: switched to fallback provider (${fallback})`, { reason }); diff --git a/src/memory/manager.mistral-provider.test.ts b/src/memory/manager.mistral-provider.test.ts index 211d77b91fe..3345b01933c 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/src/memory/manager.mistral-provider.test.ts @@ -3,10 +3,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js"; import type { EmbeddingProvider, EmbeddingProviderResult, MistralEmbeddingClient, + OllamaEmbeddingClient, OpenAiEmbeddingClient, } from "./embeddings.js"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; @@ -36,7 +38,7 @@ function buildConfig(params: { workspaceDir: string; indexPath: string; provider: "openai" | "mistral"; - fallback?: "none" | "mistral"; + fallback?: "none" | "mistral" | "ollama"; }): OpenClawConfig { return { agents: { @@ -144,4 +146,51 @@ describe("memory manager mistral provider wiring", () => { expect(internal.openAi).toBeUndefined(); expect(internal.mistral).toBe(mistralClient); }); + + it("uses default ollama model when activating ollama fallback", async () => { + const openAiClient: OpenAiEmbeddingClient = { + baseUrl: "https://api.openai.com/v1", + headers: { authorization: "Bearer openai-key" }, + model: "text-embedding-3-small", + }; + const ollamaClient: OllamaEmbeddingClient = { + baseUrl: "http://127.0.0.1:11434", + headers: {}, + model: DEFAULT_OLLAMA_EMBEDDING_MODEL, + embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]), + }; + createEmbeddingProviderMock.mockResolvedValueOnce({ + requestedProvider: "openai", + provider: createProvider("openai"), + openAi: openAiClient, + } as EmbeddingProviderResult); + createEmbeddingProviderMock.mockResolvedValueOnce({ + requestedProvider: "ollama", + provider: createProvider("ollama"), + ollama: ollamaClient, + } as EmbeddingProviderResult); + + const cfg = buildConfig({ workspaceDir, indexPath, provider: "openai", fallback: "ollama" }); + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + if (!result.manager) { + throw new Error(`manager missing: ${result.error ?? "no error provided"}`); + } + manager = result.manager as unknown as MemoryIndexManager; + const internal = manager as unknown as { + activateFallbackProvider: (reason: string) => Promise; + openAi?: OpenAiEmbeddingClient; + ollama?: OllamaEmbeddingClient; + }; + + const activated = await internal.activateFallbackProvider("forced ollama fallback"); + expect(activated).toBe(true); + expect(internal.openAi).toBeUndefined(); + expect(internal.ollama).toBe(ollamaClient); + + const fallbackCall = createEmbeddingProviderMock.mock.calls[1]?.[0] as + | { provider?: string; model?: string } + | undefined; + expect(fallbackCall?.provider).toBe("ollama"); + expect(fallbackCall?.model).toBe(DEFAULT_OLLAMA_EMBEDDING_MODEL); + }); }); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 36460df87ad..1d2fb49e88b 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -13,6 +13,7 @@ import { type EmbeddingProviderResult, type GeminiEmbeddingClient, type MistralEmbeddingClient, + type OllamaEmbeddingClient, type OpenAiEmbeddingClient, type VoyageEmbeddingClient, } from "./embeddings.js"; @@ -48,14 +49,22 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem protected readonly workspaceDir: string; protected readonly settings: ResolvedMemorySearchConfig; protected provider: EmbeddingProvider | null; - private readonly requestedProvider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "auto"; - protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral"; + private readonly requestedProvider: + | "openai" + | "local" + | "gemini" + | "voyage" + | "mistral" + | "ollama" + | "auto"; + protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; protected fallbackReason?: string; private readonly providerUnavailableReason?: string; protected openAi?: OpenAiEmbeddingClient; protected gemini?: GeminiEmbeddingClient; protected voyage?: VoyageEmbeddingClient; protected mistral?: MistralEmbeddingClient; + protected ollama?: OllamaEmbeddingClient; protected batch: { enabled: boolean; wait: boolean; @@ -185,6 +194,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem this.gemini = params.providerResult.gemini; this.voyage = params.providerResult.voyage; this.mistral = params.providerResult.mistral; + this.ollama = params.providerResult.ollama; this.sources = new Set(params.settings.sources); this.db = this.openDatabase(); this.providerKey = this.computeProviderKey(); @@ -289,9 +299,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem return merged; } - const keywordResults = hybrid.enabled - ? await this.searchKeyword(cleaned, candidates).catch(() => []) - : []; + // If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only. + const keywordResults = + hybrid.enabled && this.fts.enabled && this.fts.available + ? await this.searchKeyword(cleaned, candidates).catch(() => []) + : []; const queryVec = await this.embedQueryWithTimeout(cleaned); const hasVector = queryVec.some((v) => v !== 0); @@ -299,7 +311,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem ? await this.searchVector(queryVec, candidates).catch(() => []) : []; - if (!hybrid.enabled) { + if (!hybrid.enabled || !this.fts.enabled || !this.fts.available) { return vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults); }