diff --git a/CHANGELOG.md b/CHANGELOG.md index 9352bcc3312..c0aed689853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai - CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1. - TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. - TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer. +- Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr. - Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. - Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. - Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 547a1c8ad8f..90b0ae11d97 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -933,6 +933,86 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("diversifies mixed session and memory search results so memory hits are retained", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + sessions: { enabled: true }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search" && args.includes("workspace-main")) { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([{ docid: "m1", score: 0.6, snippet: "@@ -1,1\nmemory fact" }]), + ); + return child; + } + if (args[0] === "search" && args.includes("sessions-main")) { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([ + { docid: "s1", score: 0.99, snippet: "@@ -1,1\nsession top 1" }, + { docid: "s2", score: 0.95, snippet: "@@ -1,1\nsession top 2" }, + { docid: "s3", score: 0.91, snippet: "@@ -1,1\nsession top 3" }, + { docid: "s4", score: 0.88, snippet: "@@ -1,1\nsession top 4" }, + ]), + ); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager(); + const inner = manager as unknown as { + db: { prepare: (_query: string) => { all: (arg: unknown) => unknown }; close: () => void }; + }; + inner.db = { + prepare: (_query: string) => ({ + all: (arg: unknown) => { + switch (arg) { + case "m1": + return [{ collection: "workspace-main", path: "memory/facts.md" }]; + case "s1": + case "s2": + case "s3": + case "s4": + return [ + { + collection: "sessions-main", + path: `${String(arg)}.md`, + }, + ]; + default: + return []; + } + }, + }), + close: () => {}, + }; + + const results = await manager.search("fact", { + maxResults: 4, + sessionKey: "agent:main:slack:dm:u123", + }); + + expect(results).toHaveLength(4); + expect(results.some((entry) => entry.source === "memory")).toBe(true); + expect(results.some((entry) => entry.source === "sessions")).toBe(true); + await manager.close(); + }); + it("logs and continues when qmd embed times out", async () => { vi.useFakeTimers(); cfg = { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 8dd91a33ec1..0a1d656ca87 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -492,7 +492,7 @@ export class QmdMemoryManager implements MemorySearchManager { source: doc.source, }); } - return this.clampResultsByInjectedChars(results.slice(0, limit)); + return this.clampResultsByInjectedChars(this.diversifyResultsBySource(results, limit)); } async sync(params?: { @@ -1271,6 +1271,52 @@ export class QmdMemoryManager implements MemorySearchManager { return clamped; } + private diversifyResultsBySource( + results: MemorySearchResult[], + limit: number, + ): MemorySearchResult[] { + const target = Math.max(0, limit); + if (target <= 0) { + return []; + } + if (results.length <= 1) { + return results.slice(0, target); + } + const bySource = new Map(); + for (const entry of results) { + const list = bySource.get(entry.source) ?? []; + list.push(entry); + bySource.set(entry.source, list); + } + const hasSessions = bySource.has("sessions"); + const hasMemory = bySource.has("memory"); + if (!hasSessions || !hasMemory) { + return results.slice(0, target); + } + const sourceOrder = Array.from(bySource.entries()) + .toSorted((a, b) => (b[1][0]?.score ?? 0) - (a[1][0]?.score ?? 0)) + .map(([source]) => source); + const diversified: MemorySearchResult[] = []; + while (diversified.length < target) { + let emitted = false; + for (const source of sourceOrder) { + const next = bySource.get(source)?.shift(); + if (!next) { + continue; + } + diversified.push(next); + emitted = true; + if (diversified.length >= target) { + break; + } + } + if (!emitted) { + break; + } + } + return diversified; + } + private shouldSkipUpdate(force?: boolean): boolean { if (force) { return false;