diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index f091705d036..01d7c6506f9 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -194,6 +194,32 @@ describe("resolveMemoryBackendConfig", () => { expect(custom.path).toBe(path.resolve("/workspace/root", "notes")); }); + it("normalizes direct file qmd paths to escaped exact-file patterns", async () => { + const workspaceDir = await createFixtureDir("direct-file-path"); + const notesPath = path.join(workspaceDir, "notes{a,b}[1].md"); + await fs.writeFile(notesPath, "# Notes\n", "utf8"); + + const cfg = { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "main", workspace: workspaceDir }], + }, + memory: { + backend: "qmd", + qmd: { + paths: [{ path: "notes{a,b}[1].md", name: "direct-note", pattern: "**/*.md" }], + }, + }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const custom = resolved.qmd?.collections.find((c) => c.name.startsWith("direct-note")); + expect(custom).toMatchObject({ + path: workspaceDir, + pattern: String.raw`notes\{a,b\}\[1\].md`, + }); + }); + it("scopes qmd collection names per agent", () => { const cfg = { agents: { diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 61e0bd7efdb..ba387270b28 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -20,6 +20,10 @@ import { import { isPathInside } from "./fs-utils.js"; import { normalizeLowercaseStringOrEmpty } from "./string-utils.js"; +function escapeQmdExactFilePattern(fileName: string): string { + return fileName.replace(/[\\*?[\]{}()!+@]/g, "\\$&"); +} + export type ResolvedMemoryBackendConfig = { backend: MemoryBackend; citations: MemoryCitationsMode; @@ -291,26 +295,40 @@ function resolveCustomPaths( return; } let resolved: string; + let collectionPath: string; try { resolved = resolvePath(trimmedPath, workspaceDir); } catch { return; } - const pattern = entry.pattern?.trim() || "**/*.md"; - const dedupeKey = `${resolved}\u0000${pattern}`; + collectionPath = resolved; + let pattern = entry.pattern?.trim() || "**/*.md"; + try { + const stat = fs.statSync(resolved); + if (stat.isFile()) { + // When the configured path points directly to a file, normalize into a + // parent-directory collection with an exact-filename pattern, regardless + // of any user-supplied glob (a glob does not apply to a single file). + collectionPath = path.dirname(resolved); + pattern = escapeQmdExactFilePattern(path.basename(resolved)); + } + } catch { + // not a file or can't stat, use as-is + } + const dedupeKey = `${collectionPath}\u0000${pattern}`; if (seenRoots.has(dedupeKey)) { return; } seenRoots.add(dedupeKey); const explicitName = entry.name?.trim(); const baseName = - explicitName && !isPathInsideRoot(resolved, workspaceDir) + explicitName && !isPathInsideRoot(collectionPath, workspaceDir) ? explicitName : scopeCollectionBase(explicitName || `custom-${index + 1}`, agentId); const name = ensureUniqueName(baseName, existing); collections.push({ name, - path: resolved, + path: collectionPath, pattern, kind: "custom", });