memory: tighten multimodal config and watcher matching

This commit is contained in:
Gustavo Madeira Santana
2026-03-11 21:35:33 +00:00
parent 78d5dcfb77
commit 985efee0d8
5 changed files with 93 additions and 7 deletions

View File

@@ -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", () => {

View File

@@ -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".',
);

View File

@@ -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;

View File

@@ -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<string, unknown>,
];
expect(watchedPaths).toEqual(
expect.arrayContaining([
path.join(extraDir, "**", "*.[pP][nN][gG]"),
path.join(extraDir, "**", "*.[wW][aA][vV]"),
]),
);
});
});

View File

@@ -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,