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..3b58d95ad15 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -282,6 +282,84 @@ 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, + }, + ]); + 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, and the spare + // memory quota should be backfilled by the remaining wiki result. + expect(corpora).toContain("memory"); + expect(corpora).toContain("wiki"); + expect(details.results).toHaveLength(5); + expect( + details.results.filter((entry) => entry.corpus === "wiki").map((entry) => entry.path), + ).toEqual(["w1.md", "w2.md", "w3.md", "w4.md"]); + }); + 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..56216bf932e 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -5,6 +5,7 @@ import { jsonResult, readNumberParam, readStringParam, + type MemoryCorpusSearchResult, type OpenClawConfig, } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import type { @@ -35,6 +36,50 @@ import { searchMemoryCorpusSupplements, } from "./tools.shared.js"; +type MemorySearchToolResult = + | (Record & { corpus: "memory"; score: number; path: string }) + | MemoryCorpusSearchResult; + +function sortMemorySearchToolResults(results: T[]): T[] { + return results.toSorted((left, right) => { + if (left.score !== right.score) { + return right.score - left.score; + } + return left.path.localeCompare(right.path); + }); +} + +function mergeMemorySearchCorpusResults(params: { + memoryResults: MemorySearchToolResult[]; + supplementResults: MemorySearchToolResult[]; + maxResults: number; + balanceCorpora: boolean; +}): MemorySearchToolResult[] { + const memoryResults = sortMemorySearchToolResults(params.memoryResults); + const supplementResults = sortMemorySearchToolResults(params.supplementResults); + if (!params.balanceCorpora || memoryResults.length === 0 || supplementResults.length === 0) { + return sortMemorySearchToolResults([...memoryResults, ...supplementResults]).slice( + 0, + params.maxResults, + ); + } + + const perCorpusCap = Math.ceil(params.maxResults / 2); + const selectedMemory = memoryResults.slice(0, perCorpusCap); + const selectedSupplements = supplementResults.slice(0, perCorpusCap); + const selected = [...selectedMemory, ...selectedSupplements]; + if (selected.length < params.maxResults) { + selected.push( + ...sortMemorySearchToolResults([ + ...memoryResults.slice(selectedMemory.length), + ...supplementResults.slice(selectedSupplements.length), + ]).slice(0, params.maxResults - selected.length), + ); + } + + return sortMemorySearchToolResults(selected).slice(0, params.maxResults); +} + function buildRecallKey( result: Pick, ): string { @@ -319,14 +364,15 @@ export function createMemorySearchTool(options: { corpus: requestedCorpus, }) : []; - const results = [...surfacedMemoryResults, ...supplementResults] - .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)); + // Wiki and memory scores use incomparable scales, so corpus=all first + // balances candidate selection and then backfills any unused slots. + const effectiveMax = Math.max(1, maxResults ?? 10); + const results = mergeMemorySearchCorpusResults({ + memoryResults: surfacedMemoryResults, + supplementResults, + maxResults: effectiveMax, + balanceCorpora: requestedCorpus === "all", + }); return jsonResult({ results, provider, diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index 15131431db6..51ee87eeebf 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -578,6 +578,62 @@ describe("searchMemoryWiki", () => { }); }); + it("includes memory results and backfills wiki capacity for all-corpus search", async () => { + const { rootDir, config } = await createQueryVault({ + initialize: true, + config: { + search: { backend: "shared", corpus: "all" }, + }, + }); + for (const index of [1, 2, 3, 4, 5]) { + await fs.writeFile( + path.join(rootDir, "entities", `alpha-${index}.md`), + renderWikiMarkdown({ + frontmatter: { + pageType: "entity", + id: `entity.alpha.${index}`, + title: `Alpha ${index}`, + }, + body: `# Alpha ${index}\n\nalpha wiki ${index}\n`, + }), + "utf8", + ); + } + const manager = createMemoryManager({ + searchResults: [ + { + path: "MEMORY.md", + startLine: 4, + endLine: 8, + score: 0.9, + snippet: "alpha durable memory", + source: "memory", + citation: "MEMORY.md#L4-L8", + }, + ], + }); + getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); + + const results = await searchMemoryWiki({ + config, + appConfig: createAppConfig(), + query: "alpha", + maxResults: 5, + }); + + expect(results).toHaveLength(5); + expect(results.some((result) => result.corpus === "memory")).toBe(true); + expect( + results.filter((result) => result.corpus === "wiki").map((result) => result.path), + ).toEqual([ + "entities/alpha-1.md", + "entities/alpha-2.md", + "entities/alpha-3.md", + "entities/alpha-4.md", + ]); + expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 5 }); + }); + it("uses the active session agent for shared memory search", async () => { const { config } = await createQueryVault({ initialize: true, diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index 720e6e7a38b..0dbd5680714 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -183,6 +183,43 @@ type QuerySearchOverrides = { searchCorpus?: WikiSearchCorpus; }; +function sortWikiSearchResults(results: WikiSearchResult[]): WikiSearchResult[] { + return results.toSorted((left, right) => { + if (left.score !== right.score) { + return right.score - left.score; + } + return left.title.localeCompare(right.title); + }); +} + +function mergeWikiSearchCorpusResults(params: { + wikiResults: WikiSearchResult[]; + memoryResults: WikiSearchResult[]; + maxResults: number; + balanceCorpora: boolean; +}): WikiSearchResult[] { + const wikiResults = sortWikiSearchResults(params.wikiResults); + const memoryResults = sortWikiSearchResults(params.memoryResults); + if (!params.balanceCorpora || wikiResults.length === 0 || memoryResults.length === 0) { + return sortWikiSearchResults([...wikiResults, ...memoryResults]).slice(0, params.maxResults); + } + + const perCorpusCap = Math.ceil(params.maxResults / 2); + const selectedWiki = wikiResults.slice(0, perCorpusCap); + const selectedMemory = memoryResults.slice(0, perCorpusCap); + const selected = [...selectedWiki, ...selectedMemory]; + if (selected.length < params.maxResults) { + selected.push( + ...sortWikiSearchResults([ + ...wikiResults.slice(selectedWiki.length), + ...memoryResults.slice(selectedMemory.length), + ]).slice(0, params.maxResults - selected.length), + ); + } + + return sortWikiSearchResults(selected).slice(0, params.maxResults); +} + async function listWikiMarkdownFiles(rootDir: string): Promise { const files = ( await Promise.all( @@ -1219,14 +1256,12 @@ export async function searchMemoryWiki(params: { ) : []; - return [...wikiResults, ...memoryResults] - .toSorted((left, right) => { - if (left.score !== right.score) { - return right.score - left.score; - } - return left.title.localeCompare(right.title); - }) - .slice(0, maxResults); + return mergeWikiSearchCorpusResults({ + wikiResults, + memoryResults, + maxResults, + balanceCorpora: effectiveConfig.search.corpus === "all", + }); } export async function getMemoryWikiPage(params: {