From 44c3d8ea2efd01d04b7ef861b54a5a46a05f4dfa Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sun, 17 May 2026 09:52:04 -0700 Subject: [PATCH] fix(memory): preserve qmd lexical search for hyphenated queries (#81423) --- CHANGELOG.md | 1 + .../src/memory/qmd-manager.test.ts | 74 +++++++++++++++++++ .../memory-core/src/memory/qmd-manager.ts | 11 ++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 886a437d15b..5eccac45ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing. - CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c. - Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing `thought_signature` 400s. Fixes #72879. (#80358) Thanks @abnershang. +- Memory/QMD: keep lexical search on raw hyphenated queries while normalizing semantic QMD sub-searches, avoiding fallback to the builtin index for dashed identifiers and dates. Fixes #81328. - Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded `memory index` warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521. - Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin. - Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris. diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index ed312ef717e..dc62a70dea0 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -2698,6 +2698,80 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("keeps hyphenated tokens in lexical QMD searches while normalizing semantic searches", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + const child = createMockChild({ autoClose: false }); + if (isMcporterCommand(cmd) && args[0] === "call") { + expect(args[1]).toBe("qmd.query"); + const callArgs = JSON.parse(args[args.indexOf("--args") + 1]); + expect(callArgs.searches).toEqual([ + { type: "lex", query: "sqlite-vec-qmd backend health 2026-05-04 multi-agent" }, + { type: "vec", query: "sqlite vec qmd backend health 2026 05 04 multi agent" }, + { type: "hyde", query: "sqlite vec qmd backend health 2026 05 04 multi agent" }, + ]); + emitAndClose(child, "stdout", JSON.stringify({ results: [] })); + return child; + } + emitAndClose(child, "stdout", "[]"); + return child; + }); + + const { manager } = await createManager(); + await manager.search("sqlite-vec-qmd backend health 2026-05-04 multi-agent", { + sessionKey: "agent:main:slack:dm:u123", + }); + await manager.close(); + }); + + it("normalizes hyphenated tokens for vector-only QMD searches", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "vsearch", + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + const child = createMockChild({ autoClose: false }); + if (isMcporterCommand(cmd) && args[0] === "call") { + expect(args[1]).toBe("qmd.query"); + const callArgs = JSON.parse(args[args.indexOf("--args") + 1]); + expect(callArgs.searches).toEqual([{ type: "vec", query: "sqlite vec backend health" }]); + emitAndClose(child, "stdout", JSON.stringify({ results: [] })); + return child; + } + emitAndClose(child, "stdout", "[]"); + return child; + }); + + const { manager } = await createManager(); + await manager.search("sqlite-vec backend health", { + sessionKey: "agent:main:slack:dm:u123", + }); + await manager.close(); + }); + it("falls back to QMD <1.1 tool names when query tool is not found", async () => { // qmdMcpToolVersion is an instance field — each createManager() starts fresh. diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 3944854c51a..339aa7cd602 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -1952,21 +1952,22 @@ export class QmdMemoryManager implements MemorySearchManager { query: string, searchCommand?: string, ): Array<{ type: string; query: string }> { + const semanticQuery = normalizeQmdSemanticQuery(query); switch (searchCommand) { case "search": // BM25 keyword search only return [{ type: "lex", query }]; case "vsearch": // Vector search only - return [{ type: "vec", query }]; + return [{ type: "vec", query: semanticQuery }]; case "query": case undefined: default: // Full hybrid: lex + vec + hyde (query expansion) return [ { type: "lex", query }, - { type: "vec", query }, - { type: "hyde", query }, + { type: "vec", query: semanticQuery }, + { type: "hyde", query: semanticQuery }, ]; } } @@ -3149,3 +3150,7 @@ function resolveQmdManagerRuntimeConfig( contextLimits: resolveAgentContextLimits(cfg, agentId), }; } + +function normalizeQmdSemanticQuery(query: string): string { + return query.replace(/(\w)-(?=\w)/g, "$1 "); +}