diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 4aa3b2f5ebe..1d04b730351 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -176,6 +176,31 @@ describe("memory search config", () => { modalities: [], maxFileBytes: 10 * 1024 * 1024, }); + expect(resolved?.provider).toBe("gemini"); + }); + + it("does not enforce multimodal provider validation when no modalities are active", () => { + const cfg = asConfig({ + agents: { + defaults: { + memorySearch: { + provider: "openai", + model: "text-embedding-3-small", + fallback: "openai", + multimodal: { + enabled: true, + modalities: [], + }, + }, + }, + }, + }); + const resolved = resolveMemorySearchConfig(cfg, "main"); + expect(resolved?.multimodal).toEqual({ + enabled: true, + modalities: [], + maxFileBytes: 10 * 1024 * 1024, + }); }); it("rejects multimodal memory on unsupported providers", () => { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index b3f37081a2a..d00dae70639 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig, MemorySearchConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { SecretInput } from "../config/types.secrets.js"; import { + isMemoryMultimodalEnabled, normalizeMemoryMultimodalSettings, supportsMemoryMultimodalEmbeddings, type MemoryMultimodalSettings, @@ -377,8 +378,9 @@ export function resolveMemorySearchConfig( if (!resolved.enabled) { return null; } + const multimodalActive = isMemoryMultimodalEnabled(resolved.multimodal); if ( - resolved.multimodal.enabled && + multimodalActive && !supportsMemoryMultimodalEmbeddings({ provider: resolved.provider, model: resolved.model, @@ -388,7 +390,7 @@ export function resolveMemorySearchConfig( 'agents.*.memorySearch.multimodal requires memorySearch.provider = "gemini" and model = "gemini-embedding-2-preview".', ); } - if (resolved.multimodal.enabled && resolved.fallback !== "none") { + if (multimodalActive && resolved.fallback !== "none") { throw new Error( 'agents.*.memorySearch.multimodal does not support memorySearch.fallback. Set fallback to "none".', ); diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index d59d4ff04d8..f883c0d53b6 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -36,7 +36,7 @@ import { } from "./internal.js"; import { type MemoryFileEntry } from "./internal.js"; import { ensureMemoryIndexSchema } from "./memory-schema.js"; -import { classifyMemoryMultimodalPath } from "./multimodal.js"; +import { buildCaseInsensitiveExtensionGlob, classifyMemoryMultimodalPath } from "./multimodal.js"; import type { SessionFileEntry } from "./session-files.js"; import { buildSessionEntry, @@ -388,11 +388,15 @@ export abstract class MemoryManagerSyncOps { watchPaths.add(path.join(entry, "**", "*.md")); if (this.settings.multimodal.enabled) { for (const modality of this.settings.multimodal.modalities) { - const pattern = + const extensions = modality === "image" - ? "*.{jpg,jpeg,png,webp,gif,heic,heif}" - : "*.{mp3,wav,ogg,opus,m4a,aac,flac}"; - watchPaths.add(path.join(entry, "**", pattern)); + ? [".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic", ".heif"] + : [".mp3", ".wav", ".ogg", ".opus", ".m4a", ".aac", ".flac"]; + for (const extension of extensions) { + watchPaths.add( + path.join(entry, "**", buildCaseInsensitiveExtensionGlob(extension)), + ); + } } } continue; diff --git a/src/memory/manager.watcher-config.test.ts b/src/memory/manager.watcher-config.test.ts index 77221df34b6..43682183676 100644 --- a/src/memory/manager.watcher-config.test.ts +++ b/src/memory/manager.watcher-config.test.ts @@ -106,4 +106,50 @@ describe("memory watcher config", () => { expect(ignored?.(path.join(workspaceDir, "memory", ".venv", "lib", "python.md"))).toBe(true); expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"))).toBe(false); }); + + it("watches multimodal extensions with case-insensitive globs", async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-watch-")); + extraDir = path.join(workspaceDir, "extra"); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "PHOTO.PNG"), "png"); + + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "gemini", + model: "gemini-embedding-2-preview", + fallback: "none", + store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } }, + sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + extraPaths: [extraDir], + multimodal: { enabled: true, modalities: ["image", "audio"] }, + }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(result.manager).not.toBeNull(); + if (!result.manager) { + throw new Error("manager missing"); + } + manager = result.manager as unknown as MemoryIndexManager; + + expect(watchMock).toHaveBeenCalledTimes(1); + const [watchedPaths] = watchMock.mock.calls[0] as unknown as [ + string[], + Record, + ]; + expect(watchedPaths).toEqual( + expect.arrayContaining([ + path.join(extraDir, "**", "*.[pP][nN][gG]"), + path.join(extraDir, "**", "*.[wW][aA][vV]"), + ]), + ); + }); }); diff --git a/src/memory/multimodal.ts b/src/memory/multimodal.ts index 45796071055..40c1707f512 100644 --- a/src/memory/multimodal.ts +++ b/src/memory/multimodal.ts @@ -50,6 +50,15 @@ export function isMemoryMultimodalEnabled(settings: MemoryMultimodalSettings): b return settings.enabled && settings.modalities.length > 0; } +export function buildCaseInsensitiveExtensionGlob(extension: string): string { + const normalized = extension.trim().replace(/^\./, "").toLowerCase(); + if (!normalized) { + return "*"; + } + const parts = Array.from(normalized, (char) => `[${char.toLowerCase()}${char.toUpperCase()}]`); + return `*.${parts.join("")}`; +} + export function classifyMemoryMultimodalPath( filePath: string, settings: MemoryMultimodalSettings,