diff --git a/CHANGELOG.md b/CHANGELOG.md index 84821e93f4a..81cc91c6e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. - TUI/escape abort: track the in-flight runId after `chat.send` resolves so pressing Esc during the gap before the first gateway event aborts the run instead of repeatedly printing `no active run`. Fixes #1296. Thanks @Lukavyi and @romneyda. diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index 8f8d455cf54..8c357070e92 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -282,6 +282,88 @@ describe("memory tools", () => { expect(getMemorySearchManagerMockCalls()).toBe(0); }); + it("includes memory results in corpus=all even when wiki scores are numerically higher (#77337)", async () => { + // Wiki uses integer point scores (up to ~100+); memory uses cosine similarity (0-1). + // Raw-score sort would starve memory hits when maxResults <= number of wiki hits. + setMemorySearchImpl(async () => [ + { + path: "memory/note-a.md", + startLine: 1, + endLine: 2, + score: 0.9, + snippet: "Memory result A", + source: "memory" as const, + }, + { + path: "memory/note-b.md", + startLine: 1, + endLine: 2, + score: 0.8, + snippet: "Memory result B", + source: "memory" as const, + }, + ]); + registerMemoryCorpusSupplement("memory-wiki", { + search: async () => [ + { + corpus: "wiki", + path: "w1.md", + title: "W1", + kind: "entity", + score: 50, + snippet: "wiki 1", + }, + { + corpus: "wiki", + path: "w2.md", + title: "W2", + kind: "entity", + score: 40, + snippet: "wiki 2", + }, + { + corpus: "wiki", + path: "w3.md", + title: "W3", + kind: "entity", + score: 30, + snippet: "wiki 3", + }, + { + corpus: "wiki", + path: "w4.md", + title: "W4", + kind: "entity", + score: 20, + snippet: "wiki 4", + }, + { + corpus: "wiki", + path: "w5.md", + title: "W5", + kind: "entity", + score: 10, + snippet: "wiki 5", + }, + ], + get: async () => null, + }); + + const tool = createMemorySearchToolOrThrow(); + const result = await tool.execute("call_all_starvation", { + query: "note", + corpus: "all", + maxResults: 5, + }); + const details = result.details as { results: Array<{ corpus: string; path: string }> }; + const corpora = details.results.map((r) => r.corpus); + + // Memory results must appear despite lower numeric scores. + expect(corpora).toContain("memory"); + expect(corpora).toContain("wiki"); + expect(details.results.length).toBeLessThanOrEqual(5); + }); + it("merges memory and wiki corpus search results for corpus=all", async () => { registerMemoryCorpusSupplement("memory-wiki", { search: async () => [ diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index bcbf73cf84a..df78272b0a8 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -319,14 +319,26 @@ export function createMemorySearchTool(options: { corpus: requestedCorpus, }) : []; - const results = [...surfacedMemoryResults, ...supplementResults] + // When both corpora are present (corpus=all), wiki and memory scores + // use incomparable scales (integer point vs cosine similarity), so a + // raw-score sort lets wiki hits starve memory hits (#77337). + // Cap each corpus at ceil(maxResults/2) before the joint sort so both + // corpora are guaranteed representation in the final slice. + const effectiveMax = Math.max(1, maxResults ?? 10); + const perCorpusCap = + requestedCorpus === "all" && supplementResults.length > 0 + ? Math.ceil(effectiveMax / 2) + : effectiveMax; + const cappedMemory = surfacedMemoryResults.slice(0, perCorpusCap); + const cappedSupplements = supplementResults.slice(0, perCorpusCap); + const results = [...cappedMemory, ...cappedSupplements] .toSorted((left, right) => { if (left.score !== right.score) { return right.score - left.score; } return left.path.localeCompare(right.path); }) - .slice(0, Math.max(1, maxResults ?? 10)); + .slice(0, effectiveMax); return jsonResult({ results, provider,