Memory/QMD: diversify mixed-source search results

This commit is contained in:
Vignesh Natarajan 2026-02-20 20:13:24 -08:00
parent d7a7ebb75a
commit 544c213d42
3 changed files with 128 additions and 1 deletions

View File

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

View File

@ -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 = {

View File

@ -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<MemorySource, MemorySearchResult[]>();
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;